Overview
Still Hear is a cinematic 2.5D side-scroller puzzle/stealth adventure game (inspired by titles like Little Nightmares and Inside) developed in Unreal Engine 5.5.
๐ฌ Official Trailer
๐ฎ Full Gameplay Playthrough
The game explores the critical balance between two opposing forces required for life to persist:
- Sound (represented by the Sound Mother): Symbol of life, colors, and harmony. A world completely devoid of sound dies.
- Silence (represented by the Void Father): Symbol of emptiness, fog, and death. A world completely devoid of silence goes mad.
Guide the fragile Keeper through the remnants of a fading world.
Protagonists & Gameplay Loop
Players control Keepo, navigating environmental hazards and patrol routes accompanied by Kee, a reactive companion:
- Keepo (The Keeper): The last Sound Keeper, a fragile creature made of glass with roots and flowers growing on his body. To restore sound to the altars, he must sacrifice himself piece by piece, growing weaker with each step.
- Kee (The Companion): A living melody shaped from Keepo's essence. A luminous companion that reacts emotionally to the gameplay, changing color and shape: Happy/Normal (white/yellow, lights up dark places), Scared (when falling or near dangers), and Angry (when blasting sound waves to stun enemies or activate Tuning Forks).
Stealth & Sound Control: Player movement generates sound waves that alert enemies. Crouching makes movement silent, but some blind underground enemies (Worms) can still sense vibrations. The gameplay revolves around physics puzzles, distance-based puzzles, and a unique Resonance QTE to restore altars and destroy obstacles.
Core Contributions (Overview)
For this project, I acted as a core generalist programmer, designing and implementing the technical foundations while touching virtually every aspect of the game's development. The key points listed below highlight my primary areas of focus, but do not represent the full extent of my contributions or the systems I worked on and am capable of implementing.
- Save System & Persistence: Engineered an asynchronous serialization subsystem (
USaveSubsystem) using stable GUID components and custom file archives to prevent gameplay hitches. - Level Streaming & Checkpoints: Created a dynamic manager (
ASceneManager) controlling level streaming, spawn points, and death loops based on directional dot-product trigger checkpoints. - Persistent State-Driven Audio: Built a tag-driven persistent audio framework (
UGameAudioSubsystem) featuring real-time audio thread stereo balancing and distance-based muffled attenuation filters. - Gameplay Ability System (GAS): Extensively integrated GAS to serve as the project's backbone, controlling character attributes, status effects, and all player/enemy gameplay abilities.
- Game Flow & Flowgraph Nodes: Integrated the Flowgraph framework to organize sequential tutorial blocks and event queues, developing custom C++ nodes (e.g.
FlowNode_PlayMusic) to expose seamless audio controls directly to level designers. - Editor Tooling: Built an Editor Utility Widget (
EUW_SaveDebug) to optimize testing, allowing designers to load, inspect, and wipe local save profiles directly in-editor. - Physics, Mocap & Cutscenes: Optimized Chaos physical destructions using offline cache playbacks, integrated markerless motion capture pipelines, and managed cinematic cameras via Sequencer.
- Performance & Asset Optimization: Conducted CPU/GPU profiling to resolve bottlenecks, implemented texture channel packing (MRAO), optimized light and shadow properties, disabled heavy dynamic systems (Lumen/Nanite/VSM), and supported the art team in managing mesh LODs and static/movable light setups.
Technical Deep Dives
Expand the panels below to inspect the detailed technical design, class architectures, and C++ implementations built for this project.
Deep Dive: Save System & State Persistence
This subsystem manages the persistence of the game world, level states (Level Streaming), player progression, advanced graphics/audio settings, and the node-based scripting system (Flowgraph).
1. System Architecture & Separation of Concerns
The system is centralized within a C++ Game Instance Subsystem (USaveSubsystem), ensuring a persistent lifecycle across the entire application session, independent of map loads and unloads. The architectural design separates responsibilities into two distinct data flows:
- Game Slot State (
USaveGameObject): Handles slot-specific gameplay saves (supporting up to 3 slots). This includes actor transforms and states in the world, unlocked abilities, the active audio state stack, and the logical state of the Flowgraph. - Global Settings & Progression (
USettingsSaveGame): Manages global data decoupled from individual game slots, such as audio volumes, control settings (keybindings, vibration intensity), localization, graphics parameters (Gamma, Upscaler settings), and globally collected items.
2. Deterministic Actor Tracking (Stable GUID Tracking)
In Unreal Engine, actor memory pointers and instance names (e.g., BP_Door_C_12) mutate every time a level is reloaded or a session restarts. To map saved states back to their corresponding actors, I implemented a tracking system based on deterministic GUIDs:
USaveIdComponent: A component attached to any actor that requires its state to be persisted.- Deterministic GUID Generation: To prevent collisions and ensure consistency across builds, GUIDs for statically placed level actors are calculated using a deterministic hash of their hierarchical path in the level:
const FName StableLevelKey = USaveSubsystem::GetStableLevelKey(World, Level); const FString StablePath = StableLevelKey.ToString() + TEXT(".") + Owner->GetName(); return FGuid::NewDeterministicGuid(StablePath); - Persistent Destruction Management: Upon loading a level, the subsystem compares the actors currently present with those stored in the save file. If a static actor possesses a
USaveIdComponentbut is absent from the save file's dictionary (indicating it was previously collected or destroyed), it is automatically removed from the world:if (!Saved) { ActorsToDestroy.Add(Actor); continue; } - Dynamic Actors: Dynamically spawned actors are marked with a
"DynamicallySpawned"tag. TheGetSaveId()function returns a null GUID for these actors, allowing the subsystem to skip standard static serialization and avoid redundancies.
3. Binary Serialization Pipeline (Custom Serialization)
To maximize performance and minimize disk footprint, the system bypasses text formats (like JSON/XML) and writes directly to binary buffers (TArray<uint8>) utilizing Unreal Engine's reflection APIs.
- Custom Proxy Archive (
FSaveCoreArchive): Inherits fromFObjectAndNameAsStringProxyArchive. It configures specific engine flags to control the serialization process:ArIsSaveGame = true: Restricts serialization exclusively to fields decorated with theUPROPERTY(SaveGame)specifier.ArNoDelta = true: Disables Unreal's delta-saving optimizations, forcing the archive to serialize all candidate properties even if they match their default class values (ensuring accurate state resets).
- Serialization Hooks (
ISavable): Actors and components can implement the C++/BlueprintISavableinterface to execute custom preparation logic before saving (OnPreSave) and setup procedures after loading (OnPostLoad), e.g., refreshing UI, restoring materials, or updating animation variables. - Editor Tooling (
EUW_SaveDebug): A custom Editor Utility Widget that lets designers verify state restoration, reload levels from previous checkpoints, and wipe save files directly inside the editor viewport.
4. Level Streaming Integration
In modern games where submaps are dynamically loaded and unloaded in the background (Level Streaming), a naive save system would lose the state of unloaded actors. The subsystem solves this by binding to engine-level delegates:
- Delegate Binding:
FWorldDelegates::LevelAddedToWorld: Intercepts the loading of a sublevel and immediately applies its saved data.FWorldDelegates::LevelRemovedFromWorld: Scans the sublevel's actors right before unloading, serializing their state, and storing it in theCurrentSaveobject to prevent data loss.
- Next-Tick Deferral (Race Condition Resolution): Deserializing save data during actor construction or
BeginPlaycan lead to conflicts as the level initializes. The subsystem defers state application to the next frame using the timer manager:World->GetTimerManager().SetTimerForNextTick([this, WeakLevel]() { if (ULevel* SafeLevel = WeakLevel.Get()) { ApplyLevelInternal(SafeLevel); } });
5. Subsystem Synergies & Integrations
The USaveSubsystem orchestrates data synchronization across multiple modules:
- Flowgraph (Logical Scripting & Quests): Captures the active states of execution nodes via
FlowSubsystem->OnGameSavedand restores them viaFlowSubsystem->OnGameLoaded, persisting active puzzle progress, quest logs, and dialogue checkpoints. - Audio State Stack: Automatically saves and restores the active audio tag stack by communicating with the
UGameAudioSubsystem. - Hardware & Upscaling Settings (DLSS / FSR / TSR): Mapped configuration profiles to execute immediate console variable (CVar) adjustments:
- NVIDIA DLSS: Configures quality profiles (DLAA, Auto, Quality, Performance) using the
UDLSSLibraryAPI and managesr.ScreenPercentage. - AMD FSR & Unreal TSR: Configures the super-resolution and Frame Generation CVars.
- NVIDIA DLSS: Configures quality profiles (DLAA, Auto, Quality, Performance) using the
6. Technical Victories
- Asynchronous Save Operations: Uses Unreal's asynchronous save APIs (
AsyncSaveGameToSlot) to run disk I/O operations on background worker threads, eliminating frame rate hitching (micro-stuttering) at checkpoints. - Level Streaming Race Condition Fix: Deferred data application to the next tick relative to
BeginPlay, resolving initialization conflicts. - World State Integrity: Ensured persistent destruction of items: if an actor is missing from the save dictionary, it is swept from the level during streaming.
- Stable Keys: Hashed deterministic level paths to bypass unstable instance names across Editor and Package builds.
Deep Dive: Save System & Flowgraph Integration
The Flowgraph is a node-based visual scripting system (Unreal Engine plugin) used to orchestrate gameplay logic, including quests, dialogues, cinematic sequences, and environmental triggers. To ensure logical game progression persists accurately across sessions, the USaveSubsystem is tightly integrated with the UFlowSubsystem.
1. Bidirectional Save & Load Pipeline
Flowgraph persistence relies on transmitting transit objects of type UFlowSaveGame, which contain the binary serialization of active components (FlowComponents) and graph instances (FlowInstances).
A. The Save Flow
When a save is requested (e.g., at a checkpoint or via the pause menu), BuildSaveObject gathers the active Flowgraph state synchronously on the Game Thread before launching the asynchronous disk write:
if (UGameInstance* GI = GetGameInstance())
{
if (UFlowSubsystem* FlowSubsystem = GI->GetSubsystem<UFlowSubsystem>())
{
// Create transit object for Flowgraph
TObjectPtr<UFlowSaveGame> FlowSave = NewObject<UFlowSaveGame>();
// FlowSubsystem populates the object with the current state of all active graphs
FlowSubsystem->OnGameSaved(FlowSave);
// Transfer serialized data to the primary SaveGame object
SaveObj->FlowComponents = FlowSave->FlowComponents;
SaveObj->FlowInstances = FlowSave->FlowInstances;
}
}
B. The Load Flow
During slot loading (LoadFromSlot), the process is reversed. Data extracted from the save file is passed back to UFlowSubsystem to reconstruct active graph instances and restore individual node states:
if (UGameInstance* GI = GetGameInstance())
{
if (UFlowSubsystem* FlowSubsystem = GI->GetSubsystem<UFlowSubsystem>())
{
// Reconstruct transit object with data read from disk
TObjectPtr<UFlowSaveGame> FlowSave = NewObject<UFlowSaveGame>(this);
FlowSave->FlowComponents = CurrentSave->FlowComponents;
FlowSave->FlowInstances = CurrentSave->FlowInstances;
// Restore and re-activate graph logic
FlowSubsystem->OnGameLoaded(FlowSave);
}
}
2. Checkpoint Architecture & UI Data Mapping
This integration also manages the visual presentation of checkpoints in the game's Load Game screen:
- ID Registration: When a Checkpoint node in a Flowgraph executes, it sets a unique identifier (
CheckpointId) viaSetCurrentCheckpoint(CheckpointId)and triggers a save request. - DataTable Lookup: This ID acts as a foreign key for a
CheckpointDisplayDataDataTable, which contains the visual metadata for that checkpoint. - Soft References for Memory Optimization: Checkpoint background images in the UI are stored as
TSoftObjectPtr<UTexture2D>. This prevents the engine from loading all checkpoint textures into RAM simultaneously; the texture is asynchronously loaded only when the player highlights that specific save slot in the menu.
bool USaveSubsystem::GetCheckpointDisplayData(FName CheckpointId, FText& OutName, TSoftObjectPtr<UTexture2D>& OutImage) const
{
const ASceneManager* SM = GetSceneManager();
UDataTable* Table = SM ? SM->GetCheckpointDataTable() : nullptr;
if (!Table) return false;
// Fallback to default checkpoint if the ID is none
const FName IdToUse = CheckpointId.IsNone() ? SM->GetDefaultCheckpointId() : CheckpointId;
if (const FCheckpointDisplayData* Row = Table->FindRow<FCheckpointDisplayData>(IdToUse, TEXT("")))
{
OutName = Row->DisplayName;
OutImage = Row->BackgroundImage; // Asynchronous loading handled by the UI
return true;
}
return false;
}
3. Session Security: Preventing Data Bleeding
A major architectural challenge in persistent systems is Data Bleedingโwhere state from a previous session remains active in RAM when a player returns to the main menu and starts a new game or loads a different slot.
To ensure clean session transitions, ResetGameSession() explicitly resets the Flowgraph state before loading a new context:
void USaveSubsystem::ResetGameSession()
{
CurrentSave = nullptr;
CurrentSlotIndex = -1;
CurrentSlot = TEXT("");
if (UGameInstance* GI = GetGameInstance())
{
if (UFlowSubsystem* FlowSubsystem = GI->GetSubsystem<UFlowSubsystem>())
{
// 1. Immediately abort all executing logical graphs
FlowSubsystem->AbortActiveFlows();
// 2. Force-clear the internal state by loading a null payload
FlowSubsystem->OnGameLoaded(nullptr);
}
// ... Reset other systems like Audio ...
}
}
4. Technical Victories
- Event-Driven Saving: Checkpoint saves are triggered automatically by the Flowgraph via the
OnCheckpointRequestedSaveevent, maintaining decoupling between level design scripting and C++ subsystems. - Asynchronous UI Loading: Combining DataTables with
TSoftObjectPtroptimizes memory footprint and UI responsiveness. - Clean State Lifecycle: Invoking
AbortActiveFlows()andOnGameLoaded(nullptr)prevents overlapping logical states between consecutive gameplay sessions.
Deep Dive: Scene Manager & Game World Lifecycle Control
The ASceneManager is the central coordination actor of the game world. It manages the persistent level's lifecycle, coordinates asynchronous Level Streaming, orchestrates character spawn/respawn sequences, and handles transitions between Menu Mode and active Gameplay Mode. By implementing the ISavable interface, the Scene Manager serializes the world state and restores sublevel configurations when a player loads a saved game.
1. Persistent Save State (ISavable Interface)
The Scene Manager serializes essential properties decorated with UPROPERTY(SaveGame) to reconstruct the layout and logical context of the level:
StreamingLevelsLoadedFromSave: An array of sublevel names that must be loaded and visible upon state restoration.PlayerStartLocation/PlayerStartRotation: Spawn coordinates and orientation matching the last activated checkpoint.CurrentCheckpointPriority: A numeric value representing the latest checkpoint's priority, preventing players from polluting save slots when backtracking.SavedRootFlowInstanceName: The identifier of the active logical flow graph to restore the correct tutorial or quest state.
2. Asynchronous Level Streaming State Machine
Visual level loading in Unreal Engine is asynchronous. Initiating a streaming transition while another is active can cause state corruption or memory anomalies.
A. Concurrency Guard
The system implements a logical guard (bIsLevelTransitionActive) to queue overlapping transition requests, deferring execution to the next frame:
if (bIsLevelTransitionActive)
{
TWeakObjectPtr<ASceneManager> WeakThis(this);
GetWorld()->GetTimerManager().SetTimerForNextTick([WeakThis, SlotIndex, bAllowFullReload]()
{
if (ASceneManager* SafeThis = WeakThis.Get())
SafeThis->LoadSlotAndTransition(SlotIndex, bAllowFullReload);
});
return true;
}
B. "Hide-then-Show" Controlled Cycle
To perform a clean transition during slot loading or player respawning, the Scene Manager executes a sequential flow:
- Hide Phase: Calculates visible levels not present in the target set, hides them asynchronously using
SetShouldBeVisible(false), and binds toOnLevelHidden. - Wait for Hiding: Tracks progress using an atomic counter (
IncrementHiddenCount). - Show Phase: Once the world is cleared, triggers
ShowStreamingLevelsand binds toOnLevelShown. - Completion: Once the visible levels count matches the expected total, it fires
OnAllLevelsShown()to trigger spawn logic, fade out loading screens, and launch the Flowgraph.
3. Death & Respawn Sequence
Upon character death, the Scene Manager isolates game timing, resets level visibilities, and restores interactive actors:
- Time Dilation Reset: Resets any custom time dilation (slow-motion effects induced by skills or parries) via
UTimeManagementSubsystemto ensure respawn sequences play at normal speed (1.0). - Pristine State Broadcast: Dispatches
OnRequestWorldResettoUStillHearGameInstanceto restore all interactive level actors (traps, hazards, enemy units, collectibles) to their baseline parameters. - Teleport & Revive: Relocates the player character to
PlayerStartLocationand invokesRevive()to disable ragdoll physics, restore collisions, and re-enable character movement input.
4. The "Full-Reload Hand-off" (Preventing State Memory Leaks)
A primary challenge in save systems is "dirty" actor states in memory when switching between saves or starting a New Game. Visually reloading sublevels does not clear global variables, engine Garbage Collectors, or singleton classes. The Scene Manager solves this via a persistent map reload hand-off:
bool ASceneManager::LoadSlotAndTransition(const int32 SlotIndex, const bool bAllowFullReload)
{
if (bAllowFullReload)
{
// 1. Reset the active session and cancel pending flows
SaveSub->ResetGameSession();
// 2. Temporarily cache the target slot index in the Game Instance
if (UStillHearGameInstance* StillHearGI = GetWorld()->GetGameInstance<UStillHearGameInstance>())
{
StillHearGI->PendingLoadGameSlotIndex = SlotIndex;
}
// 3. Trigger OpenLevel to load a fresh, immaculate instance of the persistent map
UGameplayStatics::OpenLevel(this, SaveSub->GetStableMapKey(GetWorld()));
return true;
}
// On BeginPlay() of the newly reloaded persistent map, the Scene Manager checks
// PendingLoadGameSlotIndex and re-calls this method with bAllowFullReload = false
// to apply the actual save data onto a clean memory Slate.
bLoaded = SaveSub->LoadFromSlot(SlotIndex, 0);
// ... Final loading execution ...
}
5. Technical Victories
- Asynchronous Streaming State-Machine: Developed a robust transition system to stream multiple sublevels concurrently without blocking the game thread.
- Full-Reload Leak Mitigation: Prevented cross-session state bleeding by reloading the persistent map and handing off indices before deserialization.
- Dynamic Main Menu Camera: Integrates with
ACameraVolumeactors to position and orient the main menu camera dynamically based on the coordinates of the player's last active checkpoint.
Deep Dive: State-Driven Persistent Audio
In modern games, audio and music must dynamically shift according to gameplay conditions (e.g., transitioning between exploration, alert, combat, or ambient environments like caves) while maintaining persistence across saves. If a player saves inside a cavern with active high-priority tension music and custom reverb, loading that save must instantly reconstruct the exact acoustic mood.
This project resolves this by implementing a stack-based audio manager driven by Gameplay Tags (UGameAudioSubsystem), integrated natively with the streaming levels and the Save Subsystem.
1. The Core Pattern: Stack-Based State Management (Gameplay Tags)
Instead of triggering sound cues imperatively (e.g., PlaySound2D), the audio layer is managed declaratively using a stack of Gameplay Tags (AudioStateStack).
- PushAudioState(StateTag): When a player enters a new zone (e.g.,
State.Audio.Cave) or triggers combat (State.Audio.Combat), its tag is pushed to the top of the stack. - PopAudioState(StateTag): When the state ends, the tag is popped. The active state automatically reverts to the previous tag on the stack (e.g., returning to exploration music after defeating enemies).
void UGameAudioSubsystem::PushAudioState(FGameplayTag StateTag)
{
if (!StateTag.IsValid()) return;
// Safety boundary: Prevent unbound stack growth
if (AudioStateStack.Num() > 100)
{
AudioStateStack.RemoveAt(0);
}
// Avoid duplicate pushes of the same tag if it is already in the stack
if (AudioStateStack.Contains(StateTag))
{
return;
}
AudioStateStack.Add(StateTag);
RefreshAudioState();
}
2. Data-Driven Configuration (UAudioStateConfig)
Acoustic behaviors for each state are defined inside a Data Asset in the editor (UAudioStateConfig), mapping tags to a FAudioStateInfo structure:
- Priority (int32): Higher priority tags (e.g., Combat = 100) override lower priority tags (e.g., Explore = 10), determining which asset should play if multiple tags coexist in the stack.
- bPersistAcrossLevels (bool): Determines if an audio state survives sublevel transitions (e.g., level-wide background music) or should be discarded (e.g., local stealth alerts).
- Soft References (TSoftObjectPtr<USoundBase>): Audio tracks are referenced asynchronously. They are loaded synchronously (
LoadSynchronous()) only when their state becomes active, significantly optimizing RAM usage.
3. Evaluation & Crossfading (RefreshAudioState)
Every time the stack updates, `RefreshAudioState` evaluates Music and Ambience tracks independently, applying custom crossfades to prevent abrupt acoustic cuts:
void UGameAudioSubsystem::RefreshAudioState(bool bForce)
{
if (AudioStateStack.Num() == 0) {
// No active tags: Fade out all sound assets
StopMusic(1.0f);
StopAmbience(1.0f);
return;
}
const FAudioStateInfo* BestMusicState = nullptr;
int32 MaxMusicPriority = -1;
// Scan the stack for the active state with the highest priority that overrides music
for (const FGameplayTag& Tag : AudioStateStack)
{
const FAudioStateInfo* Info = AudioStateConfig->FindStateInfo(Tag);
if (Info && Info->bOverrideMusic && Info->Priority >= MaxMusicPriority)
{
MaxMusicPriority = Info->Priority;
BestMusicState = Info;
}
}
// If the evaluated state differs from the currently playing asset
if (BestMusicState)
{
USoundBase* MusicAsset = BestMusicState->MusicTrack.LoadSynchronous();
if (CurrentMusic != MusicAsset || bForce)
{
// Execute asynchronous crossfade
CrossfadeMusic(MusicAsset, BestMusicState->FadeOutTime, BestMusicState->FadeInTime, BestMusicState->MusicVolumeMultiplier);
CurrentMusic = MusicAsset;
ActiveMusicStateTag = BestMusicState->StateTag;
}
}
}
4. Persistence & Save Subsystem Integration
The `USaveSubsystem` captures and restores the entire audio stack during slot saving and loading.
- Save Flow: During save execution, `BuildSaveObject` caches the active tag list:
SaveObj->AudioStateStack = AudioSS->GetAudioStateStack(); - Load Flow: Loading a slot restores the stack list and triggers a forced refresh (
bForce = true) to bypass cache checks and instantly reconstruct the audio mix:void UGameAudioSubsystem::SetAudioStateStack(const TArray<FGameplayTag>& NewStack) { AudioStateStack = NewStack; RefreshAudioState(true); // Forced reload (bypasses cache validation) }
5. Audio Thread Stereo Panning & Distance Filters
The subsystem also includes two highly specific positioning systems implemented for the game's 2.5D perspective:
- Custom Stereo Submix (
WorldStereoBalanceSubmix): To make the player feel the distance between the two characters in a 2.5D space, I wrote a custom submix effect running directly on the audio thread. It calculates the real-time distance and pan balance of the characters relative to the camera viewport, shifting the stereo weight dynamically. - Distance Filters (
UAudioDistanceTuningData): Real-time low-pass distance filters and heartbeat sounds are calculated in C++ and smoothly interpolated in Blueprint, muffling the environment when characters are separated. UStillHearAudioObserver: A WorldSubsystem that coordinates local gameplay components (UAudioStateComponent) to automatically trigger boss, combat, or ambient music states.
6. Global Volume Management (SoundClass & SoundMix)
Settings configurations (Master, Music, SFX, UI, Dialogue, and Ambience) are managed via standard USoundClass hierarchies. The subsystem controls these buses at runtime by dynamically adjusting the active USoundMix modifier (SMX_RunTimeSettings) using parameters loaded from `USettingsSaveGame`. This ensures user preferences are respected across map changes and level transitions.
7. Architectural Results & Benefits
- Zero Audio Memory Leaks: By utilizing
TSoftObjectPtrand on-demand loading, heavy music files are kept off the RAM until their state actually executes. - Seamless Checkpoint Restoration: Reverting to a checkpoint does not reset music tracks, but restores the tag stack to maintain atmospheric continuity.
- Designer Autonomy: Priorities, fade durations, volume multipliers, and persistence rules are configured as a Data Asset in the editor, decoupling logic from raw audio files.
- Conflict Resolution: The priority-driven stack automatically resolves overlapping audio demands (e.g., terminating combat music cleanly restores the underlying ambient track).
Deep Dive: Gameplay Ability System (GAS)
Utilized Epic's Gameplay Ability System as the foundational framework for player and enemy actions:
1. Base Architecture & Attributes
- Base Actor (
AStillHearCharacterBase): ImplementsIAbilitySystemInterface. Automatically hooks up the custom ASC inPossessedBy()usingInitAbilityActorInfo(). Listens to character movement changes to automatically apply loose state tags (e.g.TAG_Status_Falling). - Custom ASC (
UStillHearAbilitySystemComponent): OverridesOnRep_ActivateAbilitiesto fire `Event.Abilities.Changed` whenever capabilities change, enabling dynamic HUD updates. - Attribute Set (
UMainCharacterAttributeSet): Controls stats likeBaseSpeed,SpeedMultiplier, andMaxParryAngle. Recalculates final speeds insidePreAttributeChange()to update the movement component'sMaxWalkSpeeddynamically.
2. UE 5.5 Native Tag Bug Fix
We discovered and resolved a native bug introduced in Unreal Engine 5.5 where blocking parent tags (e.g. GameplayAbility) inside BlockAbilitiesWithTag failed to block child tags (e.g. GameplayAbility.AbilityOne). We overrode DoesAbilitySatisfyTagRequirements() in C++ (UStillHearGameplayAbility), swapping the faulty native container check with a clean ContainerB.HasAny(ContainerA) check, restoring proper hierarchical block propagation.
3. Gameplay Ability Implementations
- Parry (
UGA_Parry): Plays the defensive montage, opens a parry window collision sphere via anim notifies, and applies stun effects on target overlaps. If successful, triggers a slo-mo effect (viaUTimeManagementSubsystem), controller haptics, camera shakes, and temporary invulnerability. - Resonance (
UGA_Resonance): Performs spherical collision sweeps to locate interactive environmental targets. Coordinates with a customUResonanceManagerComponentto handle alignment QTE events and triggers interaction states. - Kee's Sound Wave (
UGA_SoundWave): Implements target scanning by projecting 3D viewport locations to 2D screen coordinates, allowing target-switching via joystick screenspace projections, and spawning homing projectiles towards the target'sUTargetSpot. - Movement Abilities:
Crouch (
GA_Crouch) handles crouching state tags; Sprint (GA_Sprint) cancels crouch and scales speed attributes via Set-By-Caller parameters; Jump (GA_Jump) tracks in-air status and waits for landing events to deactivate; Climb (GA_Climb) leverages Motion Warping targets for root-motion ledge climbing.
4. Enemy States & Death Loop
- AI Abilities: Sound Eaters execute custom attacks via GAS (Mantis dashes, Worm dolphin dives). Successful parries apply a stun ability setting the
TAG_Status_EnemyAI_Stunnedtag. - Death Loop (
GA_Death): Triggered on zero health, it cancels and blocks all other abilities using tags, activates ragdoll physics on the character skeletal mesh, and disables controller input.
5. Compile-Time Safe Tags
Declared all tags in C++ using native macros (UE_DECLARE_GAMEPLAY_TAG_EXTERN) in GameplayTags.h, ensuring compile-time safety and preventing blueprint typo hitches.
Deep Dive: Chaos Physics, Mocap & Cinematics
Integrated physical visual effects and storytelling pipelines:
- Chaos Caching: Pre-recorded complex physical environment destructions and cached them using Chaos Cache Manager, offloading CPU computational costs at runtime while keeping physics events repeatable.
- Mocap Retargeting: Captured markerless motion capture sessions, cleaned up keyframe data in Blender, and retargeted animations to the UE5 Mannequin. Read my tech guide for pipeline details: Markerless Mocap on a Budget: FreeMocap to UE5.
- Cinematic Sequencer: Directed and set up in-game cutscenes and camera tracks using Unreal's Sequencer tool, syncing character animations and triggers with narrative beats.
Deep Dive: Game Flow & Flowgraph Integration
The open-source Flowgraph framework is integrated within the Still Hear project to manage sequential game logic flows, with particular emphasis on narrative sequences, the opening tutorial, UI control, Gameplay Ability System (GAS) integration, and dynamic audio transitions.
1. Core Architecture & Persistence (ASceneManager)
The primary coordination of the FlowGraph occurs inside SceneManager.h and SceneManager.cpp, which manages the persistent scene.
- Flow Start & Restoration (
StartFlowRoot): When transitioning from menu to gameplay (TransitionToGameplay) or when all levels are loaded (OnAllLevelsShown),StartFlowRoot()is called:
void ASceneManager::StartFlowRoot()
{
if (UGameInstance* GI = GetGameInstance())
{
if (UFlowSubsystem* FlowSubsystem = GI->GetSubsystem<UFlowSubsystem>())
{
if (RootFlowAsset)
{
bool bRestored = false;
if (!SavedRootFlowInstanceName.IsEmpty())
{
// Attempt to load the existing flow instance saved on disk
FlowSubsystem->LoadRootFlow(this, RootFlowAsset, SavedRootFlowInstanceName, false);
bRestored = !FlowSubsystem->GetRootInstancesByOwner(this).IsEmpty();
}
// If no restoration occurred (e.g., New Game), create a new root flow
if (!bRestored)
{
UFlowAsset* FlowInstance = FlowSubsystem->CreateRootFlow(this, RootFlowAsset, false);
if (FlowInstance)
{
SavedRootFlowInstanceName = FlowInstance->GetName();
FlowInstance->StartFlow();
}
}
}
}
}
}
- Save System Integration: The flow state is fully integrated with the project's Save System.
ASceneManagerdeclaresSavedRootFlowInstanceNamemarked withUPROPERTY(SaveGame). During checkpoints or manual saves,USaveSubsystemserializes this instance name, and FlowGraph automatically saves the internal states of active graphs, restoring active nodes and execution steps upon loading.
2. Custom C++ Nodes Developed
To support design requirements, several custom FlowGraph nodes were authored, categorized into 4 main areas:
A. GAS Integration & Gameplay Logic
UFlowNode_AddAbility&UFlowNode_RemoveAbility: Grants or revokes abilities (e.g. Jump, Resonance, Sound Waves) on a character target. Features abPermanentUnlock/bPermanentRemoveflag to write state changes permanently to the profile save file viaUSaveSubsystem. (Reference:FlowNode_AddAbility.h/.cpp)UFlowNode_HasTag: Instantly branches the flow based on whether a target's ASC carries a specificFGameplayTag. (Reference:FlowNode_HasTag.h/.cpp)UFlowNode_OnGameplayEvent: Pauses execution to wait for a specific Gameplay Event (e.g. altar activated, crystal broken) sent to the actor's ASC, with optional payload filtering (RequiredPayloadSourceTag) to prevent false triggers. (Reference:FlowNode_OnGameplayEvent.h/.cpp)UFlowNode_OnTagRemoved: Pauses execution until a specific Gameplay Tag is removed from the target's ASC. (Reference:FlowNode_OnTagRemoved.h/.cpp)UFlowNode_TagCounter: Tracks how many times a tag is applied (e.g. enemies defeated, switches triggered). FiresThresholdReachedonce the limit is met. The variableCurrentCountis markedUPROPERTY(SaveGame)to persist across saves. (Reference:FlowNode_TagCounter.h/.cpp)UFlowNode_IdleCheck(Tutorial-Specific): Key for tutorial flow. Starts an idle timer (IdleTime). If the player completes the required action, it cancels the timer and firesTagDetected(tutorial success). If the timer expires without action, it firesTimeoutReachedto show reminders or trigger audio tips. (Reference:FlowNode_IdleCheck.h/.cpp)
B. UI Flow Management
UFlowNode_ShowWidget: Spawns and inserts a CommonUI widget class into a specific layout layer managed byUUISubsystem. Can optionally pause the game (bPauseGame), setup localized button labels (MessageText), and supports wait conditions (Timer, GameplayTag, or Manual). (Reference:FlowNode_ShowWidget.h/.cpp)UFlowNode_ClearUILayer: Instantly clears all widgets from a specific UI layer (e.g. to clean the screen after tutorials or cutscenes). (Reference:FlowNode_ClearUILayer.h/.cpp)
C. Dynamic Audio Controls
UFlowNode_PlayMusic&UFlowNode_PlayAmbience: Starts or crossfades current music/ambience to a new track with customized fade-in/fade-out times. (Reference:FlowNode_PlayMusic.h/.cpp)UFlowNode_SetAudioState: Pushes or pops GameplayTags on the audio subsystem's state stack to dynamically adjust volumes and music tracks (e.g., stealth vs. combat zones). (Reference:FlowNode_SetAudioState.h/.cpp)UFlowNode_StopAudio: Fades out music, ambience, or both. (Reference:FlowNode_StopAudio.h/.cpp)
D. Sequencer Cutscene Binding
UFlowNode_PlaySequenceWithPlayer: Extends the nativeUFlowNode_PlayLevelSequenceto dynamically bind the active Player Character to the sequencer placeholder tagged "Player" at runtime. Allows the player's custom model/materials to interact within cutscenes. (Reference:FlowNode_PlaySequenceWithPlayer.h/.cpp)
3. Practical Example: Standard Tutorial Flow
- Trigger Activation: The player enters a specific area, triggering an event volume that starts the tutorial sequence graph in FlowGraph.
- Input Block & Cutscene: A cutscene starts (
UFlowNode_PlaySequenceWithPlayer) showing Keepo approaching an obstacle. - Display Prompt: Displays a UI popup using
UFlowNode_ShowWidget(e.g., "Press [LSHIFT] to Parry") with a tag-wait condition. - Idle Verification: Simultaneously starts
UFlowNode_IdleCheck. If the player doesn't react within 5 seconds (IdleTime), a timeout executes a voice line or audio reminder. - Success: Once the player executes the parry, the tag is applied, the widget disappears, the ability is unlocked permanently (
UFlowNode_AddAbility), and the path ahead is cleared.
Deep Dive: Performance & Rendering Tuning
Conducted extensive CPU and GPU profiling using tools like the GPU Visualizer (CTRL + Shift + ;), Statistics tool, and Map Size tool to discover rendering bottlenecks and establish high optimization standards for the project, following technical art guidelines:
1. Asset & Material Optimization (Texture Packing)
- Texture Channel Packing (MRAO): Reduced the total texture footprint and sampler counts in the Master Material by packing three grayscale maps into a single texture: Metallic (Red channel), Roughness (Green channel), and Ambient Occlusion (Blue channel) using the
_MRAOsuffix. This cut texture fetch instructions by 66% for these material properties. - Texture Import Controls: Enforced power-of-two resolutions, restricted the use of unnecessary alpha channels (using Compress Without Alpha to prevent doubling memory cost), and mapped textures to correct Texture Groups (e.g.,
WorldNormalfor normal maps, disabling mips for UI elements). - Draw Call Reduction: Limited the number of material slots in static meshes (since each slot adds a draw call) and set up instancing/batching systems to merge static assets.
2. Lighting & Shadow Optimization
- Light Mobility & Type Selection: Swapped expensive Point Lights with Spotlights where possible to restrict light volume cones. Checked lighting complexity in viewport viewmodes and set light properties to
Movableonly where dynamic adjustments were strictly required. - Attenuation & Draw Distances: Strictly limited the
Attenuation RadiusandCone Angleof point and spot lights, which directly determine their rendering cost. EnabledMax Draw DistanceandMax Distance Fade Rangeto automatically cull light sources far from the camera. - Dynamic Shadow Tuning: Disabled
Cast Shadowson minor and overlapping local lights that did not contribute visually, adjustedShadow Resolution Scaleto reduce local shadow map resolutions, and disabled shadow casting on static objects already located in shadows. - Cascaded Shadow Maps (CSM): Optimized CSM on the directional light by limiting the
Dynamic Shadow Distanceand reducing theNum Dynamic Shadow Cascadesto restrict cascade divisions.
3. Global Illumination & Upscaling Pipeline (DLSS / FSR / TSR)
- Heavy Systems Disabled: Disabled Lumen, Nanite, and Virtual Shadow Maps (VSM) to eliminate high GPU/CPU overhead, building the environment with traditional static baked lightmaps (using Lightmass and Indirect Lighting Cache) and classic Cascaded Shadow Maps.
- NVIDIA DLSS Integration: Integrated the NVIDIA DLSS plugin using the C++
UDLSSLibraryAPI. Supports dynamic screen percentage scaling (r.ScreenPercentage), DLAA (native 100% deep learning anti-aliasing), and performance modes (Auto, Quality, Balanced, Performance, Ultra Performance). - AMD FSR & Frame Generation: Integrated AMD FidelityFX Super Resolution and Frame Interpolation via CVar control (
r.FidelityFX.FSR.Enabled,r.FidelityFX.FSR.QualityMode, andr.FidelityFX.FI.Enabled). - Hardware Capability Detection & Dedicated UI: Built dedicated settings widgets (
UDLSSSettingsWidget,UFSRSettingsWidget,UGraphicsSettingsWidget) that query GPU feature support at runtime to expose only compatible upscalers to the player, with seamless fallback to Unreal TSR (r.AntiAliasingMethod = 4). All upscaler states are persisted globally viaUSettingsSaveGame.
4. Collaboration & Art Team Support
- LOD & Light Guidelines: Provided technical support and established guidelines for the art team to properly structure traditional Level of Detail (LOD) models in Blender/3D software and optimize the balance between Static, Stationary, and Movable light sources to keep the level within performance budgets.
5. Cache-Based Physics Simulation (Chaos Caching)
Calculating real-time physics destruction for complex objects (Geometry Collections with hundreds of active fractures) on the CPU is highly computationally expensive. To optimize performance, the project leverages Chaos Cache Playback:
- Pre-Calculated Physics (Baking): The physics simulation is fully pre-calculated and baked within the editor, then saved directly to a
UChaosCacheCollectionasset. - Runtime Playback (AChaosCachePlayer): During gameplay, the actor avoids real-time physics simulation and instead acts as a media player (
AChaosCachePlayer), playing back the pre-recorded simulation frames. - Optimization Benefits: Achieves absolute determinism (destruction sequences play out identically for cinematic staging), eliminates CPU-side micro-stuttering, and ensures a stable frame rate.
Deep Dive: Shader Preloading & DX12 Render Pipeline Optimization
In modern graphics APIs like DirectX 12 and Vulkan, Shader Compilation Stutter represents a major optimization challenge on PC. If the engine attempts to compile a shader dynamically at runtime when an actor or VFX becomes visible, it forces the CPU and GPU to stall, disrupting frame rates. To mitigate this, the project implements a hybrid strategy combining Pipeline State Object (PSO) Caching, Shader Warmup during level streaming, and an asynchronous C++/Slate Loading Screen designed to remain responsive under heavy CPU compilation threads.
1. The Engineering Problem: Shader Stutter in DX12
Unlike DX11 where the GPU driver handled shader compilation heuristically in the background, DX12 delegates this responsibility fully to the engine. Unreal Engine 5 must compile and link:
- Vertex, Pixel, and Geometry Shaders.
- Rasterizer, Blend, and Depth-Stencil states.
This complete state collection forms a Pipeline State Object (PSO). If a single property changes (e.g., a material shifts from opaque to translucent), it generates a unique PSO. If this PSO is not pre-compiled, the rendering thread blocks, waiting for the GPU driver to compile it.
2. The Solution: PSO Caching Pipeline
We implemented a three-stage PSO Caching pipeline to collect and pre-compile shader states:
- Playtest Profiling (PSO Record): We run playtest builds with the
-RecordPSOcommand-line switch, capturing every shader combination triggered during gameplay and saving them to local.rec.upsofiles. - Cache Baking: The captured files are merged and converted via the editor commandlet into a unified
.stable.upso(Pipeline Cache) database, packaged into the final cooked build. - Startup Warmup: During game initialization and level loading (managed by
USaveSubsystemandASceneManager), the engine reads the stable cache and pre-compiles all registered PSOs. This process is parallelized across all logical CPU threads before gameplay begins.
3. Asynchronous Loading Screen: Slate C++ vs. UMG
Standard UMG (Blueprints) widgets depend on the main Game Thread's Tick cycle. If the Game Thread is saturated at 100% due to asset loading or PSO compilation, UMG animations freeze, giving the impression that the game has crashed.
To ensure a smooth user experience, the loading screen (SLoadingScreenWidget) is written entirely in native C++/Slate:
void SLoadingScreenWidget::Construct(const FArguments& InArgs)
{
const ULoadingScreenSettings* Settings = InArgs._Settings ? InArgs._Settings : GetDefault<ULoadingScreenSettings>();
const bool bIsDeathScreen = InArgs._bIsDeathScreen;
// Resolve context-specific background color, material, and label
const FLinearColor BGColor = bIsDeathScreen ? Settings->DeathBackgroundColor : Settings->BackgroundColor;
UMaterialInterface* Material = bIsDeathScreen ? Settings->DeathMaterial.LoadSynchronous() : Settings->LoadingMaterial.LoadSynchronous();
TSharedPtr<SWidget> IconWidget;
if (Material)
{
// Dynamic material instance for runtime parameter updates (managed via FGCObject)
MaterialInstance = UMaterialInstanceDynamic::Create(Material, nullptr);
MaterialBrush = MakeUnique<FSlateBrush>();
MaterialBrush->SetResourceObject(MaterialInstance);
MaterialBrush->ImageSize = Settings->IconSize;
MaterialBrush->DrawAs = ESlateBrushDrawType::Image;
IconWidget = SNew(SBox)
.WidthOverride(Settings->IconSize.X)
.HeightOverride(Settings->IconSize.Y)
[
SNew(SImage).Image(MaterialBrush.Get())
];
}
else
{
IconWidget = SNew(SCircularThrobber).Radius(Settings->ThrobberFallbackRadius);
}
// Build the visual layout natively in Slate
ChildSlot
[
SNew(SOverlay)
+ SOverlay::Slot()
[
SNew(SColorBlock).Color(BGColor)
]
+ SOverlay::Slot()
.HAlign(Settings->IconHorizontalAlignment)
.VAlign(Settings->IconVerticalAlignment)
.Padding(Settings->IconPadding)
[
IconWidget.ToSharedRef()
]
];
}
Why Slate Doesn't Freeze
- Decoupled from Viewport Tick: Slate runs at a lower subsystem level than UMG, rendering directly via the Slate Application Window rather than relying on viewport actor ticks.
- Garbage Collection Protection (
FGCObject): The widget derives fromFGCObjectand implementsAddReferencedObjectsto track dynamic materials safely without premature garbage collection during level transitions. - Fluid 60fps UX: Even when the DX12 Render Thread uploads compiled PSOs to the GPU or loads submaps asynchronously, Slate's presentation thread continues to swap buffers and display animated loading icons smoothly.
4. Shader Preloading & Level Streaming Warmup
When ASceneManager streams submaps asynchronously, actors are initialized in memory but kept hidden. To prevent grey-material pop-in and micro-stuttering on visibility activation:
- Shader Warming: We invoke Unreal Engine's Material Warmup system. As level assets are loaded, the engine queues and compiles the associated PSOs before the actors are made visible.
- Next-Tick Deferral: Deferring visibility via
SetTimerForNextTickgives the GPU driver time to bind the newly compiled PSOs, eliminating frame drops when meshes enter the rendering pipeline.
5. Technical Victories
- Eliminated PC Shader Stutter: Transitioned from dynamic, runtime DX12 compilation to a controlled startup and level loading pre-compilation phase.
- Fluid C++ Slate UX: Replaced standard UMG loading screens with a custom Slate widget to guarantee a continuous 60fps loading animation under maximum CPU load.
- VRAM & I/O Optimization: Coordinated PSO caching with asynchronous level streaming to prevent redundant shader compiles and reduce GPU memory overhead.
Extra Work & Personal Initiatives: Porting Experiments (macOS & iOS)
Beyond the primary PC/Windows target, I independently led cross-platform porting initiatives to test the versatility, pipeline adaptability, and mobile rendering scalability of Still Hear in Unreal Engine 5.5:
- Native macOS Port: Compiled, packaged, and verified full project builds for Apple Silicon and Intel Mac architectures using native Apple Metal graphics APIs.
- Mobile Prototype (iPhone 11 / iOS): Configured native Bluetooth controller / gamepad support (via iOS Enhanced Input), adjusted HUD/UI scaling for mobile screen aspect ratios, and profiled mobile Metal rendering to verify that lighting, physics, and streaming run smoothly at 60fps on real mobile hardware.