
Zombie Survival Module: The Fourth World — Escalating Waves, Two Spawner Lanes, One Elimination Pipeline
Overview
Wave survival is the spine of the genre — zombies spawn in escalating waves, chase and attack players, drop loot on elimination, and the team survives every data-driven wave or goes down together. The zombie-survival module (the zombie-survival module) is this series' first flagship end-to-end game mode, and the fourth world for One Pattern, Three Worlds — combat AI closing the loop. The NPC state machines module proved the race-per-state shape; the sidekick companion module carried it into social AI; the farming module into simulation. Now the same engine runs an enemy that hunts you: zombie_fsm_flow.RunZombieFSM is copied from the NPC state-machine module's npc_fsm_flow per that file's own in-file instructions — same loop: case: race: shape, a fresh domain enum (Roam/Chase/Attack/Dead, never guard_state), the same mood/reaction split, plus the one escalation this domain earns. Compile-verified as a set on the real UEFN compiler.
Two design facts anchor everything below. First, the set is standalone by v1 scope: custom inventory is scoped OUT (by v1 scope — pickups route through the native item_granter_device lane), so zs_loot.verse owns its own loot pipeline — our studio's LootSystem GetLootChance/GetLootWeight/DetermineLootQuality/ShouldSpawnLoot shape mined as a pattern, never a paste. Every dependency a text search would flag is an explanatory comment, not an import; the per-file compile results make the self-containment visible (zs_loot.verse PASSes standalone, 0 errors). Second, the set is session-scoped by design: zero weak_map declarations across all five files — none of the island's two-slot persistent-storage budget is spent.
| File | Responsibility |
|---|---|
zs_types.verse |
shared types, zero logic — wave phases/config/state, player_lives, the zombie FSM contracts, the manager⇄device wave_triggers contract |
zs_loot.verse |
the self-contained loot roll pipeline — chance, weight, quality, RollLoot |
zombie_brain.verse |
the custom-brain zombie: the reusable Roam→Chase→Attack→Dead engine + the concrete npc_behavior |
zs_wave_manager.verse |
the wave-authority engine — RunWaveLoop, pure Verse, no devices |
zs_mode_device.verse |
device wiring: both spawner lanes, tracker/banner/end-game, the ONE shared elimination handler |
The module, file by file
zs_types.verse — the contract layer
Types only, zero logic. The round's lifecycle is a five-phase enum (Announce/Active/Intermission/Won/Lost) with exactly one writer — zs_wave_manager.RunWaveLoop mutates wave_state, the device only reads it (the NPC state-machine module's blackboard-ownership rule, scaled up to a round). Each wave's tuning is a wave_config row — budget, spawn interval, duration cap, intermission — so "how do the waves grow" is answered entirely by an @editable array in the editor. The zombie's states are its OWN domain enum:
zombie_state<public> := enum:
Roam
Chase
Attack
Dead
zombie_reaction<public> := enum:
Damaged
Eliminated
The in-source comment above zombie_reaction states the headline: Damaged never touches CurrentState; Eliminated "is the one reaction this domain lets ESCALATE into a real MOOD change (-> Dead)". The manager⇄device handshake is three events on wave_triggers — SpawnWaveEvent(wave_config), ZombieEliminatedEvent(), AllPlayersDownEvent() — the entire cross-module contract, same idiom as the NPC state-machine module's guard_triggers.
Team survival pressure is player_lives, and its storage is one deliberately plain line:
var Lives<private> : [player]int = map{}
A class-scoped map is legal and unrestricted (the weak_map-only rule binds module-scope vars, not class fields) — and that's the point: session state, constructed in OnBegin, gone when the round ends. ReportDown(Player)<transacts> : logic decrements a life and answers the one question the round cares about — is the whole team out? — as the hook a future damage system calls (no digest-verified "player went down" device exists in this WO's surface). On true, the device layer signals AllPlayersDownEvent: the wave manager learns about team defeat as an event, like everything else.
zs_loot.verse — the self-contained loot pipeline
Four named functions mirror our studio's LootSystem shape — GetLootChance (drop chance, more generous on later waves), GetLootWeight (rarity weights shifting toward Rare from wave 8 — escalation as data, not code), DetermineLootQuality (the cumulative weighted roll), ShouldSpawnLoot (the chance gate) — composed into the ONE pipeline every elimination routes through, RollLoot. Deliberately self-contained: v1 scopes custom inventory out, and a cross-mode-dir import wouldn't compile standalone anyway (the gate globs *.verse per mode dir). RollLoot is where two compiler landmines got defused:
RollLoot<public>(WaveNumber : int) : ?loot_drop =
Roll := GetRandomFloat(0.0, 1.0)
var Result : ?loot_drop = false
if (Roll <= GetLootChance(WaveNumber)):
Rarity := DetermineLootQuality(WaveNumber)
Drop : loot_drop = loot_drop{DefId := DefIdForRarity(Rarity), Rarity := Rarity}
set Result = option{Drop}
Result
GetRandomFloat carries a no_rollback effect (materialize the roll before testing it), and loot_drop{...} archetype construction carries the same restriction as an if-expression value (build it as a statement, set a pre-declared var Result, return it as a tail expression) — see Gotchas.
zombie_brain.verse — the NPC state-machine module's engine, the fourth domain
One file holds both halves: zombie_fsm_flow (the reusable engine, mirrors npc_fsm_flow) and zombie_brain (the concrete npc_behavior, mirrors guard_brain). RunZombieFSM dispatches on Blackboard.CurrentState and races each state's body against SightedEvent/LostSightEvent/ReactionEvent; a RunPerception task samples player distances every half second and signals into it — the one isolated poll, the NPC state-machine module's own carve-out.
Two things extend the pattern beyond the guard. First, state bodies fall forward as well as back. When NavigateTo reports the target reached, RunChase's own body promotes the mood:
navigation_result.Reached =>
set Blackboard.CurrentState = zs_types.zombie_state.Attack
return
— and demotes to Roam when the chase proves Unreachable; RunAttack is symmetric, falling back to Chase when the target proves unreachable at melee range. It's the NPC state-machine module's "body decides on its own natural completion" idiom (RunReturn falling into Patrol), applied in both directions. Roaming stays within RoamRadius of the zombie's own nest — and the wander math shows this set lives in NEW SpatialMath: vector3 fields are Forward/Left/Up, not X/Y/Z:
WanderPoint := Blackboard.HomeAnchor + vector3{Forward := OffsetForward, Left := OffsetLeft, Up := 0.0}
Second — the arc's teaching moment — a reaction is allowed to escalate into a mood, exactly once. The mood/reaction split says reactions are one-shot staggers that never touch CurrentState. Eliminated is the documented exception, per mode_flow.verse's own comment: "unless that state chooses to escalate it into a real MOOD change":
HandleReaction(Blackboard : zs_types.zombie_blackboard, Reaction : zs_types.zombie_reaction) : void =
case (Reaction):
zs_types.zombie_reaction.Damaged =>
Print("zombie: reacted to damage — mood unchanged, resuming its current state")
zs_types.zombie_reaction.Eliminated =>
set Blackboard.CurrentState = zs_types.zombie_state.Dead
Why break the rule here? Because the escalation buys Dead as a real, reachable state — the one state that ends the FSM loop — instead of letting native despawn silently tear the behavior down in OnEnd. A future damage device calls zombie_brain.ReportReaction(zombie_reaction.Eliminated) (the guard_brain.ReportReaction extension-hook shape) and the FSM finishes its own story; Damaged stays a pure stagger, mood unchanged. Most reactions should stay staggers — escalate only when your domain genuinely needs a terminal state.
zs_wave_manager.verse — the wave authority
Pure Verse, no devices, no players. RunWaveLoop walks WaveConfigs, per wave: Announce (3s) → Active (set the budget, signal SpawnWaveEvent) → the wave-phase race — clear the budget, time out, or lose the whole team; first leg to finish wins:
race:
WaitForWaveClear(State, Triggers)
block:
Sleep(Config.WaveDurationSeconds)
block:
Triggers.AllPlayersDownEvent.Await()
set GameLost = true
The clear leg counts AliveCount down purely off elimination events — no polling:
WaitForWaveClear(State : zs_types.wave_state, Triggers : zs_types.wave_triggers)<suspends> : void =
loop:
Triggers.ZombieEliminatedEvent.Await()
set State.AliveCount = State.AliveCount - 1
if (State.AliveCount <= 0):
break
A lost game can't break out of the for over WaveConfigs (break is loop-only, 3581), so remaining iterations are gated behind if (GameLost = false): and no-op once the flag flips. Survive every row and the phase lands on Won.
zs_mode_device.verse — two lanes, one pipeline
The device wires both spawner lanes, and the contrast is honest. The creature lane (NorthNest/SouthNest/EastNest/WestNest, creature_spawner_device) is mass and cheap: no custom brain, the wave loop just toggles the nests on/off — each nest's Details-panel Spawn Rate / Max Spawn Count drives actual count and timing, ZombieBudget governs the wave-clear bookkeeping, and the manager's duration timeout is the honest backstop if the editor numbers don't line up. The lane deliberately never calls SpawnAt: its Position wants the OLD /UnrealEngine.com/Temporary/SpatialMath vector3, and importing that poisons bare vector3 set-wide (see Gotchas). The NPC lane (BrainNest, npc_spawner_device) spawns exactly ONE teachable custom-brain zombie per wave — Spawn() takes no position at all.
Both lanes converge on the ONE-pipeline rule: all five nests subscribe EliminatedEvent to the same handler — nowhere else signals the manager or rolls loot:
OnZombieDown(Result : device_ai_interaction_result) : void =
if (Live := LiveTriggers?):
Live.ZombieEliminatedEvent.Signal()
MaybeDrop : ?zs_loot.loot_drop = zs_loot.RollLoot(State.CurrentWave)
if (Drop := MaybeDrop?):
GrantLoot(Killer, Drop)
RollLoot inherits GetRandomFloat's no_rollback effect, so the handler materializes MaybeDrop before unwrapping it — the same fix as inside zs_loot, one level up. GrantLoot picks one of three granters (CommonLoot/UncommonLoot/RareLoot) by the rolled Rarity — never by DefId; v1 has no per-item routing. Presentation is one edge-triggered watcher: RunPresentation polls PhaseIndex and fires banners/tracker/end-game only on a phase change.
Walkthrough
Dropping it in. Place a zs_mode_device, link the four creature nests, the BrainNest (assign zombie_brain to its Character Definition asset), a player spawner, HUD banner, tracker, end-game device, and the three rarity granters. Author WaveConfigs rows in the Details panel — more rows = a longer, escalating game, zero new code.
The wave loop. OnBegin builds the triggers, subscribes every nest to OnZombieDown, assigns lives, spawns players, then races RunWaveLoop against the spawn dispatcher and the presentation watcher. Each wave resolves through the three-leg race: budget cleared → intermission and the next row; duration exceeded → move on anyway; team out of lives → Lost.
Nest wiring. The dispatcher is the only place a SpawnWaveEvent becomes real spawner calls: enable all nests, fire the one BrainNest.Spawn() — fire-and-forget; the manager only ever awaits ZombieEliminatedEvents. Every elimination, either lane, flows through the one handler: signal the counter, roll the loot.
The escalation hook in play. A real damage system wires in twice: Lives.ReportDown(Player) (signal AllPlayersDownEvent on true) and zombie_brain.ReportReaction(zombie_reaction.Eliminated) — so the FSM reaches Dead itself instead of being torn down mid-state. Damaged is already there as the free stagger.
Session now, persistent later. v1 has no persistence — lives, wave state, and blackboards are plain runtime objects. To persist "best wave reached" someday, that value becomes a FIELD on the project's ONE consolidated player_data struct — the zero-storage pattern the farming module's reshape proves out — never a new weak_map this module declares for itself.
Gotchas
This build leans on three compiler rules that recur across the series: break is loop-only, the two-SpatialMath rule, and the persistable weak_map rule (the island save budget). Its own lessons, each a real compile round-trip on the UEFN compiler:
@editable on a struct nested in a module: needs struct<concrete> on the macro, not <concrete> on the name.
compiles; wave_config<public><concrete> := struct: fails 3596, and no <concrete> at all fails 3604 once the struct sits in an @editable []T device field.
A stored logic value is not an if condition (3513) — even a freshly-bound local. Only comparisons or decides-binds satisfy an if: if (GameLost = false):, never if (GameLost):.
GetRandomFloat — and anything that calls it — carries no_rollback (3512). Never call it inside an if: condition or a logic{...} cast; materialize the roll first, test the plain value. The restriction climbs the call chain: RollLoot itself must be materialized in OnZombieDown.
Archetype construction as an if-expression's VALUE carries the same restriction (3512), even under a safe comparison — build the struct as a statement in the body, set a pre-declared var, return it as a tail expression.
break is only legal in a loop: (3581) — a for that must stop early gates its remaining iterations behind a flag comparison instead.
Missing usings fail late, at the first call site. GetFortCharacter[] needs /Fortnite.com/Characters, GetPlayspaceForEntity[] needs /Fortnite.com/Playspaces, entity needs /Verse.org/SceneGraph. Copy the NPC state-machine module's usings list wholesale.
Session vs persistent, the forward pointer. The [player]int lives map is session state on purpose — the 2-weak_map budget (the two-weak-map island limit) and the published-shape freeze (the immutable-save-shape rule) mean persistent progress belongs as a field on ONE shared player_data envelope, the pattern the farming module's zero-storage reshape documents. Never a fresh weak_map.
Get the complete code — free
You've read the full walkthrough. The complete, copy-paste-ready Verse solution is free for members — sign in to unlock it.
Free with your BrainDead.TV / BrainDeadGuild Discord account. The walkthrough above is always free.
Check your understanding
Test yourself with an interactive quiz and track your progress + earn XP — free for members.
Turn this into a guided course
Add A compile-verified five-file wave-survival flagship that closes the race-per-state arc's loop on combat AI (guard → companion → plant → zombie) — a data-driven wave authority, a custom-brain zombie running Roam/Chase/Attack/Dead with the one documented mood-escalation exception, a self-contained chance/weight/quality loot roll, and a fully session-scoped design that spends zero of the island's persistent-storage budget to your free study plan — we'll suggest related pages and stitch the lot into one compile-checked, self-guided lesson with worked examples and quizzes.
Original guide generated by Verse Island from the Verse/UEFN knowledge base, with references to the Epic Games sources above. Code is validated against the knowledge base.