Still Hear

Unreal Engine 5.5 | 2.5D Side-Scroller Puzzle Stealth

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 USaveIdComponent but 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. The GetSaveId() 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 from FObjectAndNameAsStringProxyArchive. It configures specific engine flags to control the serialization process:
    • ArIsSaveGame = true: Restricts serialization exclusively to fields decorated with the UPROPERTY(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++/Blueprint ISavable interface 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 the CurrentSave object to prevent data loss.
  • Next-Tick Deferral (Race Condition Resolution): Deserializing save data during actor construction or BeginPlay can 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->OnGameSaved and restores them via FlowSubsystem->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 UDLSSLibrary API and manages r.ScreenPercentage.
    • AMD FSR & Unreal TSR: Configures the super-resolution and Frame Generation CVars.

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) via SetCurrentCheckpoint(CheckpointId) and triggers a save request.
  • DataTable Lookup: This ID acts as a foreign key for a CheckpointDisplayData DataTable, 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 OnCheckpointRequestedSave event, maintaining decoupling between level design scripting and C++ subsystems.
  • Asynchronous UI Loading: Combining DataTables with TSoftObjectPtr optimizes memory footprint and UI responsiveness.
  • Clean State Lifecycle: Invoking AbortActiveFlows() and OnGameLoaded(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:

  1. Hide Phase: Calculates visible levels not present in the target set, hides them asynchronously using SetShouldBeVisible(false), and binds to OnLevelHidden.
  2. Wait for Hiding: Tracks progress using an atomic counter (IncrementHiddenCount).
  3. Show Phase: Once the world is cleared, triggers ShowStreamingLevels and binds to OnLevelShown.
  4. 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 UTimeManagementSubsystem to ensure respawn sequences play at normal speed (1.0).
  • Pristine State Broadcast: Dispatches OnRequestWorldReset to UStillHearGameInstance to restore all interactive level actors (traps, hazards, enemy units, collectibles) to their baseline parameters.
  • Teleport & Revive: Relocates the player character to PlayerStartLocation and invokes Revive() 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 ACameraVolume actors 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 TSoftObjectPtr and 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): Implements IAbilitySystemInterface. Automatically hooks up the custom ASC in PossessedBy() using InitAbilityActorInfo(). Listens to character movement changes to automatically apply loose state tags (e.g. TAG_Status_Falling).
  • Custom ASC (UStillHearAbilitySystemComponent): Overrides OnRep_ActivateAbilities to fire `Event.Abilities.Changed` whenever capabilities change, enabling dynamic HUD updates.
  • Attribute Set (UMainCharacterAttributeSet): Controls stats like BaseSpeed, SpeedMultiplier, and MaxParryAngle. Recalculates final speeds inside PreAttributeChange() to update the movement component's MaxWalkSpeed dynamically.

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 (via UTimeManagementSubsystem), controller haptics, camera shakes, and temporary invulnerability.
  • Resonance (UGA_Resonance): Performs spherical collision sweeps to locate interactive environmental targets. Coordinates with a custom UResonanceManagerComponent to 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's UTargetSpot.
  • 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_Stunned tag.
  • 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. ASceneManager declares SavedRootFlowInstanceName marked with UPROPERTY(SaveGame). During checkpoints or manual saves, USaveSubsystem serializes 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 a bPermanentUnlock/bPermanentRemove flag to write state changes permanently to the profile save file via USaveSubsystem. (Reference: FlowNode_AddAbility.h/.cpp)
  • UFlowNode_HasTag: Instantly branches the flow based on whether a target's ASC carries a specific FGameplayTag. (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). Fires ThresholdReached once the limit is met. The variable CurrentCount is marked UPROPERTY(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 fires TagDetected (tutorial success). If the timer expires without action, it fires TimeoutReached to 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 by UUISubsystem. 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 native UFlowNode_PlayLevelSequence to 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

  1. Trigger Activation: The player enters a specific area, triggering an event volume that starts the tutorial sequence graph in FlowGraph.
  2. Input Block & Cutscene: A cutscene starts (UFlowNode_PlaySequenceWithPlayer) showing Keepo approaching an obstacle.
  3. Display Prompt: Displays a UI popup using UFlowNode_ShowWidget (e.g., "Press [LSHIFT] to Parry") with a tag-wait condition.
  4. 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.
  5. 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 _MRAO suffix. 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., WorldNormal for 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 Movable only where dynamic adjustments were strictly required.
  • Attenuation & Draw Distances: Strictly limited the Attenuation Radius and Cone Angle of point and spot lights, which directly determine their rendering cost. Enabled Max Draw Distance and Max Distance Fade Range to automatically cull light sources far from the camera.
  • Dynamic Shadow Tuning: Disabled Cast Shadows on minor and overlapping local lights that did not contribute visually, adjusted Shadow Resolution Scale to 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 Distance and reducing the Num Dynamic Shadow Cascades to 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++ UDLSSLibrary API. 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, and r.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 via USettingsSaveGame.

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 UChaosCacheCollection asset.
  • 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:

  1. Playtest Profiling (PSO Record): We run playtest builds with the -RecordPSO command-line switch, capturing every shader combination triggered during gameplay and saving them to local .rec.upso files.
  2. 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.
  3. Startup Warmup: During game initialization and level loading (managed by USaveSubsystem and ASceneManager), 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 from FGCObject and implements AddReferencedObjects to 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 SetTimerForNextTick gives 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.

๐Ÿ“ฑ Mobile Gameplay Test on iPhone 11

โ† Back to Projects