Cinder here. You have climbed every switchback on this island — devices on the south shores, Verse in the jungle, round logic in the village, and nineteen lessons up this volcano. Today there is no new trick. Today you take everything you own and forge it into one shipped game: The Emberpeak Trials, a wave-survival mode with a full six-phase state machine, persistent best-wave progress, and a defeat path that can strike mid-wave. This is the whole game. Light the forge.
What you will build
A complete, publishable wave-survival island:
- A six-phase state machine —
Lobby → Wave → Shop → Storm → GameOver / Victory— driven by one enum and one loop, with exactly one function allowed to change phase. - Escalating creature waves, one
creature_spawner_deviceper wave, cleared by counting eliminations against each spawner's own spawn limit. - A shop intermission (the ember forge) that grants gear between waves via
item_granter_device. - A recurring ash storm phase driven from Verse through
advanced_storm_controller_device— the storm generates and dissolves on the machine's schedule, not the island's. - Persistent progress: each player's best wave survives relogs via a
<persistable>class in a module-scopedweak_map. - A defeat race: the entire wave campaign runs inside
race:against a defeat listener, so a wipe on wave 3 ends the match instantly and cleanly.
This is the capstone the whole east-volcano zone has been building toward — every prior lesson slots into this device.
Architecture — six phases, one owner each
A state machine stays honest when every phase has exactly one owner. Memorize this table; it is also your final boss quiz:
| Phase | Owner (device / module) | Entered when |
|---|---|---|
| Lobby | button_device + MatchStartEvent on the bus |
OnBegin, and nothing moves until the button fires |
| Wave | creature_spawner_device (one per wave) |
The round loop starts the next wave |
| Shop | item_granter_device (the ember forge) |
A wave clears and more waves remain |
| Storm | advanced_storm_controller_device |
Every StormEvery cleared waves |
| GameOver | trigger_device (defeat wiring) + end_game_device |
DefeatEvent wins the race |
| Victory | The round loop itself + end_game_device |
The spawner array runs dry — every wave survived |
Three rules keep the machine from melting:
- One door. Only
EnterPhasemay changeCurrentPhase. Every transition announces itself on the event bus and the HUD. - Handlers signal, phases await. Device events (
InteractedWithEvent,EliminatedEvent,TriggeredEvent) never run game logic — theySignalanevent, and the suspending phase functionsAwaitit. The machine has one timeline. - Defeat is a race, not an if. You cannot sprinkle
if (Defeated)checks through a suspending loop and catch a mid-wave wipe.race:cancels the whole campaign the instantDefeatEventfires.
Imports from other zones
This build explicitly reuses what you already shipped elsewhere on the island:
- South Shores — enum-with-data phase machine +
SpawnProp:game_phaseis a bare enum;PhaseInfoForis the companion lookup that carries each phase's banner and duration (Verse enums cannot carry fields — the struct rides alongside).SpawnPropreturns in Common patterns for volcanic set dressing. - North Jungle — event bus + helper modules:
ember_event_busis the jungle event-bus pattern grown up: fourevent()instances that decouple device handlers from phase logic, exactly like Barnaby's drum-signal relays. - Center Village — custom round logic + HUD wiring:
RunTrialsis Professor AweShucks' round loop scaled to a campaign, andEnterPhasepushes every transition to ahud_message_devicebanner just like the hub scoreboard wiring. - East Volcano, lessons 1-19: wave spawning (lessons 15-16), creature management (17), the storm controller (18), and persistable saves (8-9) all bolt straight in.
Walkthrough
Step 1 — Sketch the phases before you type
Write the six phases on paper and draw the arrows. There are only seven legal transitions in this whole game (count them in the table above — Wave can go to Shop, Storm loops back to Wave, and so on). If you cannot draw the arrow, the code must not perform the transition. This ten-minute sketch is the difference between a state machine and a state swamp.
Step 2 — The enum, the data companion, and the bus
The top of the file declares the machine's vocabulary: game_phase (six labels), phase_info + PhaseInfoFor (the south-shores enum-with-data pattern — banner text and durations live in ONE place), and ember_event_bus (four events that are the only way handlers talk to phases).
Step 3 — Persistence
ember_save is a <final><persistable> class holding BestWave, stored in a module-scoped var EmberSaves : weak_map(player, ember_save) — the exact shape from the tycoon persistence lesson. RecordWaveSurvived only writes when the new wave beats the stored best, so a bad run never erases a good one.
Step 4 — The device and its full listing
Here is the complete capstone device. It compiles as-is; every API call below is real UEFN 5.8 surface.
using { /Fortnite.com/Devices }
using { /Fortnite.com/Playspaces }
using { /Verse.org/Simulation }
# ═══════════════════════════════════════════════════════════════
# THE EMBERPEAK TRIALS — wave-survival capstone
# Six phases, one machine: Lobby → Wave → Shop → Storm → GameOver / Victory
# ═══════════════════════════════════════════════════════════════
# The six phases of the Trials. The enum is a clean label — nothing else.
game_phase := enum:
Lobby
Wave
Shop
Storm
GameOver
Victory
# Enum-with-data companion (the south-shores phase-machine pattern):
# the data rides in a lookup struct, keyed by the enum.
phase_info := struct:
Banner : string
Duration : float
PhaseInfoFor(Phase : game_phase) : phase_info =
case (Phase):
game_phase.Lobby => phase_info{Banner := "Gather at the caldera. Press the ember button to begin.", Duration := 0.0}
game_phase.Wave => phase_info{Banner := "MAGMA WAVE INCOMING — hold the ridge!", Duration := 0.0}
game_phase.Shop => phase_info{Banner := "The forge is open. Claim your gear.", Duration := 12.0}
game_phase.Storm => phase_info{Banner := "THE ASH STORM CLOSES IN — keep moving!", Duration := 20.0}
game_phase.GameOver => phase_info{Banner := "The volcano claims its due. Rest, then climb again.", Duration := 0.0}
game_phase.Victory => phase_info{Banner := "EMBERPEAK CONQUERED! The dragon bows to you.", Duration := 0.0}
# North-jungle event bus: every system talks through these events,
# so no phase function ever reaches into another phase's internals.
ember_event_bus := class:
MatchStartEvent : event(agent) = event(agent){}
PhaseChangedEvent : event(game_phase) = event(game_phase){}
WaveClearedEvent : event(int) = event(int){}
DefeatEvent : event() = event(){}
# East-volcano persistence: your best wave outlives the session.
ember_save := class<final><persistable>:
BestWave : int = 0
var EmberSaves : weak_map(player, ember_save) = map{}
# ═══ The capstone device ═══
emberpeak_trials_device := class(creative_device):
# Players press this in the Lobby to start the Trials.
@editable
StartButton : button_device = button_device{}
# One spawner per wave, in order. Five spawners = five waves.
@editable
WaveSpawners : []creature_spawner_device = array{}
# The ember forge. Set Receiving Players = All on this device.
@editable
ShopGranter : item_granter_device = item_granter_device{}
# Set Generate Storm On Game Start = No — Verse owns this storm.
@editable
Storm : advanced_storm_controller_device = advanced_storm_controller_device{}
# Phase banners for every player.
@editable
Banner : hud_message_device = hud_message_device{}
# Wire your last-player-down setup to fire this trigger.
@editable
DefeatTrigger : trigger_device = trigger_device{}
@editable
EndGame : end_game_device = end_game_device{}
# A storm phase runs after every N cleared waves.
@editable
StormEvery : int = 2
# The shared event bus — one instance, every handler signals through it.
Bus : ember_event_bus = ember_event_bus{}
# Message params need a localized value, not a raw string.
BannerMsg<localizes>(S : string) : message = "{S}"
var CurrentPhase : game_phase = game_phase.Lobby
var EliminatedCount : int = 0
var WaveLimit : int = 0
OnBegin<override>()<suspends> : void =
# Wire the edges of the state machine.
StartButton.InteractedWithEvent.Subscribe(OnStartPressed)
DefeatTrigger.TriggeredEvent.Subscribe(OnDefeatTriggered)
for (Spawner : WaveSpawners):
Spawner.Disable()
Spawner.EliminatedEvent.Subscribe(OnCreatureEliminated)
# Phase 1: Lobby. Nothing moves until someone presses the button.
EnterPhase(game_phase.Lobby)
Bus.MatchStartEvent.Await()
# The whole match: the wave loop RACES the defeat listener.
# Whichever finishes first cancels the other.
race:
RunTrials()
AwaitDefeat()
# Let the final banner breathe, then close the round.
Sleep(4.0)
if (Players := GetPlayspace().GetPlayers(), Champion := Players[0]):
EndGame.Activate(Champion)
# ── Handlers: the ONLY doors into the machine ──
OnStartPressed(Agent : agent) : void =
if (CurrentPhase = game_phase.Lobby):
Bus.MatchStartEvent.Signal(Agent)
OnDefeatTriggered(MaybeAgent : ?agent) : void =
Bus.DefeatEvent.Signal()
OnCreatureEliminated(Result : device_ai_interaction_result) : void =
set EliminatedCount += 1
if (EliminatedCount >= WaveLimit):
Bus.WaveClearedEvent.Signal(EliminatedCount)
# ── One door in and out of every state ──
EnterPhase(NewPhase : game_phase) : void =
set CurrentPhase = NewPhase
Bus.PhaseChangedEvent.Signal(NewPhase)
Info := PhaseInfoFor(NewPhase)
Banner.Show(BannerMsg(Info.Banner), ?DisplayTime := 5.0)
# ── The center-village round loop, scaled up to a full campaign ──
RunTrials()<suspends> : void =
var WaveNumber : int = 0
loop:
if (Spawner := WaveSpawners[WaveNumber]):
set WaveNumber += 1
RunWavePhase(WaveNumber, Spawner)
if (WaveNumber < WaveSpawners.Length):
RunShopPhase()
if (Mod[WaveNumber, StormEvery] = 0):
RunStormPhase()
else:
break
# Ran out of spawners = every wave survived. Crown them.
EnterPhase(game_phase.Victory)
RunWavePhase(WaveNumber : int, Spawner : creature_spawner_device)<suspends> : void =
EnterPhase(game_phase.Wave)
set EliminatedCount = 0
set WaveLimit = Spawner.GetSpawnLimit()
Spawner.Enable()
# Suspend until the elimination handler signals the wave is clear.
Bus.WaveClearedEvent.Await()
Spawner.EliminateCreatures()
Spawner.Disable()
RecordWaveSurvived(WaveNumber)
RunShopPhase()<suspends> : void =
EnterPhase(game_phase.Shop)
ShopGranter.GrantItemToAll()
Sleep(PhaseInfoFor(game_phase.Shop).Duration)
RunStormPhase()<suspends> : void =
EnterPhase(game_phase.Storm)
Storm.GenerateStorm()
Sleep(PhaseInfoFor(game_phase.Storm).Duration)
Storm.DestroyStorm()
AwaitDefeat()<suspends> : void =
Bus.DefeatEvent.Await()
EnterPhase(game_phase.GameOver)
# ── Persistence: best wave survives relogs and seasons ──
RecordWaveSurvived(WaveNumber : int) : void =
for (P : GetPlayspace().GetPlayers()):
if (Existing := EmberSaves[P], Existing.BestWave >= WaveNumber) {}
else if (set EmberSaves[P] = ember_save{BestWave := WaveNumber}) {}
Step 5 — Read the machine like the dragon does
OnBeginwires every handler, disables every spawner, entersLobby, then suspends onBus.MatchStartEvent.Await(). The button handler refuses to signal unless the machine is actually inLobby— no mid-match restarts.race:runsRunTrials()againstAwaitDefeat(). If players wipe on wave 3,DefeatEventfires,AwaitDefeatfinishes first, and the entire wave campaign — mid-Sleep, mid-Await, wherever it is — is cancelled. That is the defeat path handled in three lines.RunTrialswalks the spawner array withloop:+ indexing (aforbody cannot make suspending calls, so the campaign usesloop). When indexing fails, the array is spent: every wave survived,Victory.RunWavePhaseresets the counters, readsGetSpawnLimit()from the spawner itself (the wave size lives on the device, where designers can tune it), enables the spawner, and suspends onWaveClearedEvent. The elimination handler counts kills and signals when the wave is clear.RunStormPhasecallsGenerateStorm()/DestroyStorm()— remember lesson 18: the device's Generate Storm On Game Start option must be No, or the island fights your Verse for custody of the storm.EnterPhaseis the single door: sets state, signals the bus, shows the banner via the<localizes>message helper.
Step 6 — The final trial
Playtest and survive wave 5. Watch the banners tick through the phases in order, wipe on purpose once to confirm the defeat race fires from inside a wave, then relog and confirm your best wave is still on record. When all three pass, the mountain is yours.
Device wiring checklist
Place these on your caldera and wire them in the Details panel:
- [ ]
emberpeak_trials_device— the Verse device, somewhere central. - [ ]
button_device→ StartButton — at the lobby gathering spot. - [ ] 5×
creature_spawner_device→ WaveSpawners (in wave order). Tune each wave's difficulty with the device's own spawn limit and creature type; wave 5 should sting. - [ ]
item_granter_device→ ShopGranter — Receiving Players = All, loaded with your shop loot. - [ ]
advanced_storm_controller_device→ Storm — Generate Storm On Game Start = No. - [ ]
hud_message_device→ Banner — default display time is overridden from Verse. - [ ]
trigger_device→ DefeatTrigger — fire it from your last-player-down wiring (e.g. an elimination manager or Down But Not Out setup transmitting on a channel that triggers it). - [ ]
end_game_device→ EndGame — victory and defeat both land here. - [ ] Island settings: spawn pads at the lobby, respawns configured for your difficulty,
StormEveryleft at 2 unless your playtesters beg.
Common patterns
Pattern 1 — Volcanic debris with SpawnProp
The south-shores SpawnProp import, put to east-volcano work: scatter lava-rock props during the storm phase for atmosphere. Call RainDebris from RunStormPhase in your own build.
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/SpatialMath }
# Volcanic set dressing: rain a few lava rocks around the device
# during the storm phase, using the south-shores SpawnProp import.
lava_debris_device := class(creative_device):
@editable
LavaRockAsset : creative_prop_asset = DefaultCreativePropAsset
RainDebris(Count : int)<suspends> : void =
Origin := GetTransform().Translation
var Index : int = 0
loop:
if (Index >= Count):
break
Offset := vector3{X := 200.0 * Index, Y := 150.0 * Index, Z := 600.0}
SpawnProp(LavaRockAsset, Origin + Offset, IdentityRotation())
Sleep(0.5)
set Index += 1
OnBegin<override>()<suspends> : void =
RainDebris(3)
Pattern 2 — The machine in miniature
Every big state machine should have a two-phase baby sibling you can rebuild from memory. Same skeleton: handler signals, OnBegin awaits.
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
# The whole capstone, shrunk to two phases. If the big machine ever
# misbehaves, rebuild THIS in five minutes and grow it back up.
mini_phase_machine := class(creative_device):
@editable
StartButton : button_device = button_device{}
StartEvent : event(agent) = event(agent){}
OnBegin<override>()<suspends> : void =
StartButton.InteractedWithEvent.Subscribe(OnPressed)
Print("Lobby: waiting for the button.")
StartEvent.Await()
Print("Round running!")
Sleep(30.0)
Print("Round over. Back to the lobby with you.")
OnPressed(Agent : agent) : void =
StartEvent.Signal(Agent)
Where this goes next
Nothing imports this lesson — this IS the summit. The Emberpeak Trials is the east-volcano capstone, and every lesson in the zone (plus the south-shores phase machine, the north-jungle event bus, and the center-village round loop) slots into the device you just shipped. Two trails remain: publish the island via the center-village publish-your-hub-island checklist, and when a wave refuses to clear at 2 a.m., take the bug west — Inkbeard's coves teach the debugging and async forensics that keep a shipped island alive. The dragon salutes you. 🔥