Unreal Engine 5.8 tutorial

Part 6 — Intermediate systems

The systems every game needs in Unreal Engine 5.8: UMG UI, MetaSounds audio, Behavior Tree AI, save games, data-driven design, networking basics, and the Gameplay Ability System.

These are the systems every real game needs. Each could be its own multi-hour tutorial; this part gives you the map and the vocabulary, then a complete, reproducible build for each one applied to WISP. By the end L_Hollow_3D goes from a prototype to something that boots from a menu, shows your Glow on a HUD, plays a chime when you grab a mote, runs the Shades on a real brain, and remembers which beacons you lit.

Everything below assumes the Content/_Wisp/ layout from the capstone brief and the actors built in Part 5: BP_Wisp (a Character with a Glow float and MaxGlow), BP_Mote, BP_Beacon, BP_Shade (a Character possessed by an AIController), and BP_HollowGameMode. We assume each beacon has an integer BeaconID and a bIsLit bool, and that BP_HollowGameMode already tracks a BeaconsLit count. We build Blueprint-first; short C++ listings appear only where they are clearer or already in play.

6.1 UI with UMG (Unreal Motion Graphics)

  • Widget Blueprint = a UI screen/component. The Designer tab is a visual layout canvas; the Graph tab is its logic (a Blueprint).
  • Layout uses panels: Canvas Panel (absolute positioning), Horizontal/Vertical Box, Grid, Overlay, Size Box, with Anchors for resolution independence.
  • Common widgets: Text, Button, Image, Progress Bar (health bars), Slider.
  • Property Binding ties a widget property to a function (e.g. health bar percent ← Health/MaxHealth). Prefer event-driven updates (update the bar when health changes) over per-frame bindings for performance.
  • Show a widget: Create WidgetAdd to Viewport (usually from the PlayerController or HUD). Remove it when done.
  • For data-heavy UI (inventories, lists), use List View / Tile View with an entry widget — they recycle widgets like a virtualized list.

6.1a Lab: WBP_HUD — an event-driven Glow meter and mote counter

The naive HUD updates the Glow bar every frame in Tick or via a per-frame binding. That works but it is wasteful and it is the wrong instinct — Glow only changes on discrete events (collect a mote, get touched by a Shade). We’ll push updates from an Event Dispatcher instead, so the bar redraws only when Glow actually changes.

Step 1 — expose the data and a dispatcher on BP_Wisp. Open BP_Wisp. Confirm the variables (from Part 2/5): Glow (Float, default 100), MaxGlow (Float, default 100). In the My Blueprint panel, under Event Dispatchers, click + and add OnGlowChanged. Open its details and add two inputs: NewGlow (Float) and NewMaxGlow (Float).

Now centralize every change to Glow through one function so you never forget to fire the dispatcher. Add a function SetGlow with input NewValue (Float):

Function SetGlow(NewValue : Float)
  Set Glow = Clamp(NewValue, 0.0, MaxGlow)
  Call OnGlowChanged (NewGlow = Glow, NewMaxGlow = MaxGlow)
  Branch (Glow <= 0.0)
    True -> Call HandleDeath   // your existing lose path (Part 5)

In Part 5 the mote and the Shade already change Glow through AddGlow / a drain — route those through this setter so the dispatcher always fires. Redefine AddGlow(Amount) as a one-liner that calls SetGlow(Glow + Amount), and have the Shade drain call SetGlow(Glow - DrainPerSecond * DeltaSeconds) (equivalently AddGlow(-DrainPerSecond * DeltaSeconds)). Every Glow change — collect, drain, restore — now funnels through SetGlow, clamps, and fires OnGlowChanged, so the HUD can never go stale.

Step 2 — build the widget. In Content/_Wisp/UI/, right-click → User InterfaceWidget Blueprint → parent class UserWidget → name it WBP_HUD. Open it; in the Designer, drag a Canvas Panel as the root if one isn’t there, then:

  • Drag a Progress Bar onto the canvas. Rename it GlowBar (tick Is Variable). Anchor it top-left; set Position X/Y ~40,40, Size X 320, Size Y 24. Under Appearance, set Fill Color and Opacity to a cyan (R0 G0.94 B1).
  • Drag a Text widget below it. Rename it MoteCountText (tick Is Variable). Set its text to a placeholder like Motes: 0.

Step 3 — an explicit update function (no per-frame binding). Switch to the Graph tab. Add a function UpdateGlow with inputs NewGlow (Float) and NewMaxGlow (Float):

Function UpdateGlow(NewGlow, NewMaxGlow)
  GlowBar -> Set Percent ( NewGlow / NewMaxGlow )

Add a second function UpdateMotes with input Count (Int):

Function UpdateMotes(Count : Int)
  MoteCountText -> Set Text ( Format Text "Motes: {0}"  with {0} = Count )

Why a function and not a Designer Property Binding? A bound property is polled every frame the widget is visible. Our UpdateGlow runs only when OnGlowChanged fires. For a single bar the cost is trivial, but the event-driven habit is the one that scales to dozens of UI elements — and it matches the “push, don’t poll” instinct you already have from app development.

Step 4 — create and bind from the PlayerController. Open your player controller (BP_HollowPlayerController; if the project still uses the template default, create one from PlayerController, set it on BP_HollowGameModePlayer Controller Class). Add a variable HUDWidget of type WBP_HUD (Object Reference). On Event BeginPlay:

Event BeginPlay
  Create Widget (Class = WBP_HUD, Owning Player = Self) -> return value
  Set HUDWidget = (return value)
  HUDWidget -> Add to Viewport (Z Order 0)
  // wire the event-driven updates:
  Get Controlled Pawn -> Cast To BP_Wisp -> (As BP_Wisp)
    Bind Event to OnGlowChanged  ( Target = As BP_Wisp,  Event = HandleGlowChanged )
    // prime the bar with the starting value:
    HUDWidget -> UpdateGlow ( NewGlow = As BP_Wisp.Glow, NewMaxGlow = As BP_Wisp.MaxGlow )

“Bind Event to OnGlowChanged” needs a target event with a matching signature. Create a Custom Event on the controller named HandleGlowChanged with inputs NewGlow (Float) and NewMaxGlow (Float), and have it call HUDWidget -> UpdateGlow with those pins. (Drag off the red dispatcher pin → Add Custom Event to auto-generate the matching signature.)

Step 5 — the mote counter, end to end. The HUD mote count must be driven by the same “push, don’t poll” pattern, and — critically — a mote pickup must actually reach the HUD. BP_Mote does not hold a reference to the widget, so it cannot call UpdateMotes directly. Put the authoritative count and a dispatcher on BP_HollowGameMode (a PlayerState works too) and let the controller relay it to the widget:

  • On BP_HollowGameMode, add an integer MoteCount (default 0) and an Event Dispatcher OnMoteCountChanged with one input NewCount (Int).
  • On BP_HollowGameMode, add a function RegisterMoteCollected that increments MoteCount and broadcasts the dispatcher:
    Function RegisterMoteCollected()
      Set MoteCount = MoteCount + 1
      Call OnMoteCountChanged ( NewCount = MoteCount )
  • In BP_Mote, on the collect overlap, get the GameMode and call the function (no widget reference needed): Get Game ModeCast To BP_HollowGameModeRegisterMoteCollected.
  • In BP_HollowPlayerController (the one that created HUDWidget), on Event BeginPlay — after creating the HUD — get the GameMode, Cast To BP_HollowGameMode, and Bind Event to OnMoteCountChanged with a Custom Event HandleMoteCountChanged(NewCount : Int) that calls HUDWidget -> UpdateMotes ( Count = NewCount ). (Get Game Mode only returns a valid value on the server/standalone, which is exactly where this single-player slice runs.)

Now the chain is complete and reproducible: mote overlap → RegisterMoteCollectedOnMoteCountChanged broadcast → controller’s HandleMoteCountChangedHUDWidget.UpdateMotes(NewCount). The count lives in exactly one place (the GameMode), and the widget is a pure view that never owns game state.

Test it. Press Play. The Glow bar should sit full (100/100). Walk into a Shade — the bar drops in steps as drain ticks, not before. Grab a mote — the bar jumps up and “Motes” increments. Open the Widget Reflector (Tools → Debug → Widget Reflector) or just trust it: UpdateGlow should appear in a Blueprint debug trace only on those events, never every frame.

6.1b Lab: WBP_MainMenu and WBP_Pause

Two more widgets, both about input mode — the bit beginners trip on. A menu needs the mouse cursor and clicks routed to the UI; gameplay needs them routed to the pawn.

WBP_MainMenu. Create WBP_MainMenu in Content/_Wisp/UI/. In the Designer add a Vertical Box centered on the canvas; drop two Buttons in it, each containing a Text (Play, Quit). Rename the buttons PlayButton and QuitButton (tick Is Variable). Select each button, scroll its Details to Events, and click + next to On Clicked to generate the graph events.

// In WBP_MainMenu graph:
On Clicked (PlayButton)
  Open Level (by Name)  ( Level Name = "L_Hollow_3D" )

On Clicked (QuitButton)
  Quit Game ( Specific Player = Get Player Controller 0, Quit Preference = Quit )

The menu should live in its own small map — create L_MainMenu (an empty level is fine). Set it as the project’s Editor Startup Map and Game Default Map in Project Settings → Maps & Modes. Show the widget from that map’s controller (or a tiny dedicated BP_MenuGameMode) on BeginPlay, and crucially set the input mode:

Event BeginPlay   // menu controller
  Create Widget (Class = WBP_MainMenu, Owning Player = Self) -> Menu
  Menu -> Add to Viewport
  Set Input Mode UI Only ( Player Controller = Self, In Widget to Focus = Menu )
  Set Show Mouse Cursor = true   (on the Player Controller)

WBP_Pause. Create WBP_Pause with a Vertical Box and two buttons: ResumeButton and QuitButton. Pausing must use a controller/input path that still runs while the game clock is frozen, so drive it from an Enhanced Input action. Add IA_Pause (Digital/bool) and add it to the player’s mapping context (e.g. IMC_Player, bound to Esc / Gamepad Start). In BP_HollowPlayerController:

EnhancedInputAction IA_Pause (Triggered)
  Branch ( Is Game Paused? )
   False:
     Create Widget (WBP_Pause) -> PauseWidget  ; Add to Viewport
     Set Input Mode UI Only ( In Widget to Focus = PauseWidget )
     Set Show Mouse Cursor = true
     Set Game Paused ( Paused = true )

// In WBP_Pause graph:
On Clicked (ResumeButton)
  Set Game Paused ( Paused = false )
  Get Owning Player -> Set Input Mode Game Only
  Get Owning Player -> Set Show Mouse Cursor = false
  Remove from Parent (Self)

On Clicked (QuitButton)
  Set Game Paused ( Paused = false )
  Open Level ( Level Name = "L_MainMenu" )

Set Game Paused halts the world tick. The Pause widget’s button clicks still work because Slate UI input is processed outside the paused game tick. If your pause input itself dies while paused, set the input action’s owning component or the controller’s tick to run while paused, or (simplest) toggle pause from the widget’s Resume button and from IA_Pause only to open the menu, as above.

Test it. Launch the game — you should land on the menu with a visible cursor; Play loads L_Hollow_3D with the cursor gone and the Wisp controllable. In-game, Esc freezes everything and shows Resume/Quit; Resume unfreezes and recaptures input; Quit returns to the menu. Quit on the main menu closes a packaged build (it is a no-op in PIE).

6.2 Audio with MetaSounds

  • MetaSounds is UE5’s node-based audio system — think a Blueprint graph that synthesizes/processes sound, giving you procedural, sample-accurate audio (randomized footsteps, dynamic engine sounds) instead of static clips.
  • Sound Cue is the older, simpler system; still fine for basic needs.
  • Attenuation settings make sounds 3D/spatialized (falloff with distance, occlusion).
  • Trigger sounds via Play Sound 2D (UI/music) or Play Sound at Location / Spawn Sound Attached (world sounds). Audio Notifies on animations handle footsteps.
  • Sound Classes / Submixes group sounds for mixing (Master/Music/SFX/Voice volume sliders).

6.2a Lab: a synthesized mote-collect chime in MetaSounds

No audio files required — we synthesize the chime from oscillators so the art burden stays at zero.

Step 1 — create the source. In Content/_Wisp/Audio/, right-click → SoundsMetaSound Source → name it MS_MoteCollect. (A MetaSound Source is a playable sound; a MetaSound Patch is a reusable graph fragment you nest inside sources.) Open it. The graph starts with On Play and an Output: Out Mono/Stereo Audio plus an On Finished trigger.

Step 2 — build a plucked tone. Wire this graph:

On Play
  -> Trigger into:  AD Envelope (Attack 0.005 s, Decay 0.25 s)  // an envelope generator
        Envelope (float 0..1) ---------------------------+
  Sine Oscillator (Frequency 880 Hz)  --- audio ---+      |
  Triangle Oscillator (Frequency 1320 Hz) - audio -+--> Mixer (2 in) -- audio --+
                                                                                |
  Multiply (Audio): [Mixer audio]  x  [Envelope]  -----------------------------+--> Output Audio
  // route the envelope's "On Done" trigger -> Output: On Finished  (so the voice frees itself)

880 Hz is A5, 1320 Hz adds a bright fifth; the short attack and quarter-second decay give a “ting”. To make every pickup feel slightly different, insert a Random (Float) node seeded on each On Play and feed it (mapped to roughly ×0.95–1.05) into the oscillator frequencies. Click the speaker/play button in the MetaSound editor to audition; tweak the decay until it reads as “collected”, not “alarm”.

Step 3 — play it. The chime is a non-spatial UI-style cue, so use Play Sound 2D. In BP_Mote, on the overlap that grants Glow:

On Component Begin Overlap (collected by BP_Wisp)
  ... grant Glow / increment mote count ...
  Play Sound 2D ( Sound = MS_MoteCollect, Volume Multiplier 1.0 )
  Destroy Actor (Self)

3D world sounds need Attenuation. For the beacon-relight swell and the ambient hum you want the sound to fade with distance and pan in space. Create a Sound Attenuation asset (ATT_World), enable Spatialization and set a falloff (e.g. inner radius 600, falloff 3000), then play those via Spawn Sound at Location (one-shot at the beacon) or Spawn Sound Attached (a looping hum component on each lit beacon), passing ATT_World as the Attenuation Settings. Play Sound 2D deliberately ignores attenuation.

Mixing. Create a Sound Class hierarchy (SC_Master → SC_SFX, SC_Music, SC_UI) and assign each sound’s Sound Class. Route classes to a Submix to add effects (reverb on SFX) and to expose master/SFX/music sliders later from an options menu via Set Sound Mix Class Override on a Sound Mix. For the slice, a single SC_SFX on the chime is enough.

Test it. Play; collect a mote — you hear the ting and the Glow bar jumps on the same event. Move far from a beacon while its hum loops — the hum should get quieter and pan; the mote chime should not change with distance (it is 2D by design).

6.3 AI: Behavior Trees, Blackboard, Perception, Navigation

The standard AI stack:

  • NavMesh (Nav Mesh Bounds Volume) — defines walkable space; agents pathfind on it. Press P in the viewport to visualize it.
  • AIController — possesses the AI pawn, runs the brain.
  • Behavior Tree (BT) — a visual tree of Composites (Selector = “try children until one succeeds” / Sequence = “do children in order”), Tasks (leaf actions: MoveTo, Wait, custom), Decorators (conditions/guards on branches), and Services (run periodically to update data).
  • Blackboard — the AI’s shared memory (the “TargetActor”, “LastKnownLocation”); BT nodes read/write it.
  • AI Perception — sight/hearing senses that report stimuli (spotted the player) into the Blackboard.

Pattern: Perception sees player → writes Target to Blackboard → BT’s Selector picks the “Chase” branch → MoveTo Target → in range → Attack task. This scales from a single guard to complex behavior.

For crowds (hundreds/thousands of agents) you move to Mass (Unreal’s ECS-style framework) — relevant to the 5.8 MetaHuman Crowd workflow (experimental in 5.8).

6.3a Lab: the Shade brain — NavMesh, Blackboard, Behavior Tree, Perception

We give the Shade three moods: wander in the dark, chase the Wisp on sight, and flee when it ends up standing in beacon light.

Step 1 — lay down navigation. Open L_Hollow_3D. From the Place Actors panel drag a Nav Mesh Bounds Volume over the whole walkable zone; scale it (in the Brush Settings) to cover the floor with headroom. Press P — the floor should flood green. If it doesn’t, the volume is too small/too high, or Project Settings → Navigation System → Auto Create Navigation is off. (For a streamed World Partition zone in Part 7 you’ll switch the nav data to Dynamic; for this fixed zone Static is fine and cheaper.)

Step 2 — the Blackboard. In Content/_Wisp/Blueprints/AI/, right-click → Artificial IntelligenceBlackboardBB_Shade. Add keys:

KeyTypeMeaning
TargetActorObject (Base = Actor)The Wisp, when perceived. Null = not seen.
bInLightBoolTrue when the Shade is standing in a lit beacon’s radius.
FleeLocationVectorA dark point to run to when bInLight.
WanderLocationVectorA random reachable point to meander toward when idle.

Step 3 — the Behavior Tree. Create a Behavior Tree BT_Shade (same menu), open it, and set its Blackboard Asset to BB_Shade in the details. Build this exact tree (drag from the bottom of each node to add children; left-to-right order is priority):

ROOT
 └─ Selector
     ├─ [Decorator: Blackboard "bInLight" Is Set / Is True]  FLEE
     │    └─ Sequence
     │         ├─ Task: Find Dark Spot (custom BTTask, writes FleeLocation)
     │         └─ Task: Move To  ( Blackboard Key = FleeLocation, Acceptable Radius 50 )
     │
     ├─ [Decorator: Blackboard "TargetActor" Is Set]          CHASE
     │    └─ Sequence
     │         └─ Task: Move To  ( Blackboard Key = TargetActor, Acceptable Radius 120 )
     │            // the Glow drain is NOT a BT task: it is Part 5's Shade
     │            // overlap-drain (the Shade's sphere trigger ticks Glow down
     │            // while it overlaps BP_Wisp). MoveTo just closes the distance.
     │
     └─ Wander                                               (default / fallback)
          └─ Sequence
               ├─ Task: BTT_FindWanderLocation  (custom BTTask, writes WanderLocation)
               ├─ Task: Move To  ( Blackboard Key = WanderLocation, Acceptable Radius 50 )
               └─ Task: Wait  ( 1.0–2.5 s, random deviation )

Add a Decorator by right-clicking a composite/branch → Add Decorator → Blackboard, then set its Key Query = Is Set (for TargetActor) or Key Query = Is Equal To / Is True (for the bool bInLight). On both the FLEE and CHASE decorators set Observer Aborts = Both, so that the moment bInLight turns true the tree abandons chasing and jumps to FLEE, and the moment the Wisp is seen (or lost) the CHASE branch is re-evaluated — Both aborts the running lower-priority branch and the branch’s own subtree when the condition flips.

There are two custom leaf tasks here, and both are real BTTask_BlueprintBase subclasses (right-click in the BT → New Task); implement Event Receive Execute AI → do the work → Finish Execute (Success = true) on each:

  • BTT_FindWanderLocationGet Controlled PawnGet Actor LocationGet Random Reachable Point in Radius (Origin = that location, Radius approx 1500) → Set Blackboard Value as Vector (Key = WanderLocation) → Finish Execute (Success = true). The Wander sequence runs this task, then Move To WanderLocation.
  • BTT_FindDarkSpot — samples a few random reachable points and picks the one farthest from any lit beacon, then Set Blackboard Value as Vector on FleeLocationFinish Execute (Success = true).

Note there is no “Drain Glow” BT task: the Glow drain is the same overlap-drain you built on BP_Shade in Part 5 (a sphere trigger on the Shade that ticks the Wisp’s Glow down through SetGlow while it overlaps). CHASE just runs Move To the TargetActor; closing the distance is what brings the Shade into contact range and lets that existing trigger do the draining.

Step 4 — the AIController runs the tree and senses the Wisp. Create BP_ShadeController from AIController. Add an AIPerception component; add a Sight sense config: Sight Radius 1500, Lose Sight Radius 1800, Peripheral Vision Half Angle 60°, and under Detection by Affiliation tick Detect Neutrals (and Enemies) so it can see the player even without a Team setup. On Event BeginPlay of the controller:

Event BeginPlay (BP_ShadeController)
  Run Behavior Tree ( BTAsset = BT_Shade )

Event On Target Perception Updated (AIPerception)  // Actor, Stimulus
  Branch ( Stimulus . Successfully Sensed? )
    True  -> Cast Actor to BP_Wisp -> Set Blackboard Value as Object ( Key TargetActor = Actor )
    False -> Clear Blackboard Value ( Key TargetActor )    // lost sight

Set BP_Shade’s AI Controller Class = BP_ShadeController and its Auto Possess AI = Placed in World or Spawned.

Step 5 — feed the bInLight key. The cleanest place is the beacon: when BP_Beacon relights, it already turns on a PointLight with some radius. Give the beacon a slightly larger Sphere trigger matching the light radius; on a Shade entering a lit beacon’s sphere, get the Shade’s controller → Get BlackboardSet Value as Bool (bInLight = true); on exit (or when no lit sphere overlaps), set it false. Because of the Observer Aborts = Both on the FLEE decorator, the Shade will visibly break off and run for the dark the instant it crosses into light.

Test it. Place a Shade and Play. Out of sight it should meander (Wander). Step into its view cone — it locks on and closes distance, draining Glow on contact (watch the HUD bar). Relight a beacon next to it — it should turn and flee to a dark corner. Use the AI debugger (apostrophe key ' in PIE, then number keys) to watch the Blackboard values and the active BT branch update live.

6.4 Saving and loading

  • SaveGame object — a USaveGame subclass holding the fields you want to persist (score, position, inventory).
  • UGameplayStatics::SaveGameToSlot / LoadGameFromSlot (or async variants). Serialize your data into the SaveGame object, save to a named slot.
  • Keep the GameInstance as the coordinator (it survives level loads) and have it own the save/load logic.

6.4a Lab: BP_WispSave + a GameInstance that drives it

Step 1 — the SaveGame asset. In Content/_Wisp/Data/, create a Blueprint with parent class SaveGame → name it BP_WispSave. Add variables (all Instance Editable off is fine; they just need to be public):

VariableTypePersists
LitBeaconIDsArray of IntWhich beacons are already relit.
BestClearTimeFloatFastest full clear, in seconds (0 = none yet).
SaveSlotNameString (default "WispSlot0")Slot identity.

Step 2 — the GameInstance coordinator. Create BP_WispGameInstance from GameInstance and set it in Project Settings → Maps & Modes → Game Instance Class. Add a variable CurrentSave of type BP_WispSave. Implement three functions:

Function LoadOrCreateSave()
  Does Save Game Exist ( Slot "WispSlot0", User 0 )
    True  -> Load Game from Slot ( "WispSlot0", 0 ) -> Cast to BP_WispSave -> Set CurrentSave
    False -> Create Save Game Object ( Class = BP_WispSave ) -> Set CurrentSave

Function SaveProgress()
  Save Game to Slot ( SaveGameObject = CurrentSave, Slot "WispSlot0", User Index 0 )

Function MarkBeaconLit( BeaconID : Int )
  Branch ( CurrentSave.LitBeaconIDs  Contains  BeaconID )
    False -> Add Unique ( CurrentSave.LitBeaconIDs, BeaconID )
            Call SaveProgress()

Call LoadOrCreateSave once early — Event Init of the GameInstance is ideal (it fires before any map). When a beacon relights in BP_Beacon, after toggling its light, do: Get Game InstanceCast To BP_WispGameInstanceMarkBeaconLit(BeaconID). When BP_HollowGameMode registers a win, compute the run time and write the best:

On Win (BP_HollowGameMode)
  ClearTime = (Now seconds) - RunStartTime
  GI = Cast Game Instance to BP_WispGameInstance
  Branch ( GI.CurrentSave.BestClearTime == 0  OR  ClearTime < GI.CurrentSave.BestClearTime )
    True -> Set GI.CurrentSave.BestClearTime = ClearTime
  GI -> SaveProgress()

Step 3 — apply saved state on load. When L_Hollow_3D opens, each BP_Beacon on BeginPlay asks the GameInstance whether its BeaconID is in CurrentSave.LitBeaconIDs; if so it calls its own ApplyLitState function from Part 5 (the silent path: light on, bIsLit = true, no swell sound and no player-driven counting), not the player’s Relight interaction. Relight is the live-gameplay path — it plays the swell and assumes the player triggered it — so calling it on load would double-count and play audio for beacons that were already lit. ApplyLitState just restores visual/logical state so returning players keep their progress.

Don’t let each beacon self-restore on its own BeginPlay — actor BeginPlay order isn’t guaranteed, so nothing can know when it’s safe to count. Instead let BP_HollowGameMode drive one deterministic pass on its own BeginPlay: gather every beacon, set TotalBeacons, restore the saved ones silently through their Part 5 ApplyLitState (light on, bIsLit = true, no swell sound, no player counting — never the player’s Relight, which would replay audio and double-count), tally BeaconsLit in the same loop, and only then test the win. A returning player keeps their progress and can never be stranded one beacon short.

Event BeginPlay (BP_HollowGameMode)
  GI = Cast Game Instance to BP_WispGameInstance
  Get All Actors Of Class ( BP_Beacon ) -> Beacons
  Set TotalBeacons = Length(Beacons)
  Set BeaconsLit  = 0
  For Each ( Beacon in Beacons )
    Branch ( GI.CurrentSave.LitBeaconIDs  Contains  Beacon.BeaconID )
      True -> Beacon.ApplyLitState()          // silent restore (Part 5)
              Set BeaconsLit = BeaconsLit + 1
  // TotalBeacons and BeaconsLit are both set from the live world now
  Branch ( BeaconsLit >= TotalBeacons )
    True -> Call OnWin

The same in C++ if you prefer (the SaveGame subclass is trivial there):

UCLASS()
class UWispSave : public USaveGame
{
    GENERATED_BODY()
public:
    UPROPERTY() TArray<int32> LitBeaconIDs;
    UPROPERTY() float BestClearTime = 0.f;
};

// save / load
UWispSave* Save = Cast<UWispSave>(
    UGameplayStatics::CreateSaveGameObject(UWispSave::StaticClass()));
Save->LitBeaconIDs.AddUnique(BeaconID);
UGameplayStatics::SaveGameToSlot(Save, TEXT("WispSlot0"), 0);

if (UGameplayStatics::DoesSaveGameExist(TEXT("WispSlot0"), 0))
{
    UWispSave* Loaded = Cast<UWispSave>(
        UGameplayStatics::LoadGameFromSlot(TEXT("WispSlot0"), 0));
}

Test it. Relight two beacons, then stop and re-launch (or open L_Hollow_3D fresh). Those two beacons should already be lit on load. Finish a full clear, note the time, then beat it — BestClearTime should only ever decrease. To verify the file exists, check <Project>/Saved/SaveGames/WispSlot0.sav.

6.5 Data-driven design

  • Data Tables — import CSV/JSON into a typed table (rows = FTableRowBase structs). Perfect for item stats, enemy configs, dialogue. You edit data without touching code/Blueprints — exactly the “config over code” instinct you already have.
  • Data Assets — singleton-ish assets for designer-tuned config objects.
  • Gameplay Tags — a hierarchical tag registry (State.Stunned, Ability.Fire.Bolt) for clean, string-free state and querying.

WISP’s one good use here: a DataTable of beacon tuning. Create a struct FBeaconRow (members: BeaconID Int, LightIntensity Float, LightColor LinearColor, RequiredMotes Int) deriving from FTableRowBase, then a DataTable DT_Beacons using that row. Each BP_Beacon looks up its row by BeaconID on BeginPlay and configures its light from the data — so you tune all five beacons by editing one table, no Blueprint edits. This is also exactly the kind of bulk data Part 8’s AI agent can generate for you.

6.6 Networking (high level — know it exists early)

If multiplayer is ever a goal, design for it from the start; retrofitting is painful.

  • Unreal has a built-in client-server replication model. The server is authoritative.
  • UPROPERTY(Replicated) / ReplicatedUsing replicate variables server→clients.
  • RPCs (UFUNCTION(Server/Client/NetMulticast)) call functions across the wire.
  • The gameplay framework’s split (GameMode server-only, PlayerController per-client, PlayerState/GameState replicated) exists because of networking. This is why Part 1’s table matters.

WISP’s slice is single-player, so we don’t replicate anything. But notice the design already respects the split: Glow lives on the player’s pawn, beacon-lit count on the GameMode, save coordination on the GameInstance. If you ever co-op WISP, Glow and bIsLit become UPROPERTY(ReplicatedUsing=...) and the relight becomes a server RPC — a port, not a rewrite.

6.7 Gameplay Ability System (GAS) — for ambitious projects

GAS is Epic’s framework for abilities, attributes (health/mana), cooldowns, and status effects (the tech behind Fortnite/Paragon-style games). It’s powerful, network-ready, and has a steep learning curve. Don’t start here. Reach for it when you have a real game with many interacting abilities and you’ve felt the pain GAS solves.

Orientation only — do NOT implement GAS for WISP. For the slice, Glow as a plain float on BP_Wisp driven through SetGlow is the right tool; GAS would be massive over-engineering. For context only: in GAS, Glow would be an Attribute on an AttributeSet, the mote pickup and Shade drain would be GameplayEffects, and the dash would be a GameplayAbility — all replicated by the AbilitySystemComponent. Recognize the shapes; resist the urge until a future, bigger WISP earns it.

Exercise 6 — WISP milestone (make it a game, not a prototype)

Wrap L_Hollow_3D in real systems: a WBP_MainMenu (Play / Quit) and a WBP_Pause (Resume / Quit) that correctly toggle Set Input Mode and Set Game Paused; an event-driven WBP_HUD whose Glow Progress Bar updates from an OnGlowChanged dispatcher (never per-frame) plus a mote counter; a synthesized MS_MoteCollect MetaSound played via Play Sound 2D, with an attenuated beacon swell and ambient hum; the Shades on BT_Shade / BB_Shade (wander in the dark, chase the Wisp on sight via AIPerception, flee when bInLight), with a NavMesh Bounds Volume laid down; and a BP_WispSave driven from BP_WispGameInstance that persists the lit-beacon IDs and best clear time. Do as many as time allows — each is a self-contained skill, and together they turn the prototype into something a stranger can boot, play, and resume.