Reference Devices compiles

CAPSTONE — Volcano Survival: The Emberpeak Trials

Every zone on this island taught you one piece: south shores gave you the enum-with-data phase machine, the jungle gave you event buses and helper modules, the village gave you round loops and HUD wiring, and nineteen volcano lessons gave you spawners, storms, and persistence. The Emberpeak Trials is where you forge all of it into one shipped wave-survival game mode — a full Lobby→Wave→Shop→Storm→GameOver state machine with persistent best-wave progress, built around a single compile-ready Verse device.

Updated Examples verified on the live UEFN compiler

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 machineLobby → 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_device per 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-scoped weak_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:

  1. One door. Only EnterPhase may change CurrentPhase. Every transition announces itself on the event bus and the HUD.
  2. Handlers signal, phases await. Device events (InteractedWithEvent, EliminatedEvent, TriggeredEvent) never run game logic — they Signal an event, and the suspending phase functions Await it. The machine has one timeline.
  3. 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 instant DefeatEvent fires.

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_phase is a bare enum; PhaseInfoFor is the companion lookup that carries each phase's banner and duration (Verse enums cannot carry fields — the struct rides alongside). SpawnProp returns in Common patterns for volcanic set dressing.
  • North Jungle — event bus + helper modules: ember_event_bus is the jungle event-bus pattern grown up: four event() instances that decouple device handlers from phase logic, exactly like Barnaby's drum-signal relays.
  • Center Village — custom round logic + HUD wiring: RunTrials is Professor AweShucks' round loop scaled to a campaign, and EnterPhase pushes every transition to a hud_message_device banner 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

  • OnBegin wires every handler, disables every spawner, enters Lobby, then suspends on Bus.MatchStartEvent.Await(). The button handler refuses to signal unless the machine is actually in Lobby — no mid-match restarts.
  • race: runs RunTrials() against AwaitDefeat(). If players wipe on wave 3, DefeatEvent fires, AwaitDefeat finishes first, and the entire wave campaign — mid-Sleep, mid-Await, wherever it is — is cancelled. That is the defeat path handled in three lines.
  • RunTrials walks the spawner array with loop: + indexing (a for body cannot make suspending calls, so the campaign uses loop). When indexing fails, the array is spent: every wave survived, Victory.
  • RunWavePhase resets the counters, reads GetSpawnLimit() from the spawner itself (the wave size lives on the device, where designers can tune it), enables the spawner, and suspends on WaveClearedEvent. The elimination handler counts kills and signals when the wave is clear.
  • RunStormPhase calls GenerateStorm() / 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.
  • EnterPhase is 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_deviceStartButton — at the lobby gathering spot.
  • [ ] 5× creature_spawner_deviceWaveSpawners (in wave order). Tune each wave's difficulty with the device's own spawn limit and creature type; wave 5 should sting.
  • [ ] item_granter_deviceShopGranterReceiving Players = All, loaded with your shop loot.
  • [ ] advanced_storm_controller_deviceStormGenerate Storm On Game Start = No.
  • [ ] hud_message_deviceBanner — default display time is overridden from Verse.
  • [ ] trigger_deviceDefeatTrigger — 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_deviceEndGame — victory and defeat both land here.
  • [ ] Island settings: spawn pads at the lobby, respawns configured for your difficulty, StormEvery left 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. 🔥

Build your own lesson with emberpeak_volcano_survival

Generate a personalized, step-by-step lesson plan built around this object — grounded in this exact reference and our compile-verified knowledge base.

Build a lesson →