Reference Devices compiles

Treasure Cove Phases: the game state machine

Round-based games live and die by their game loop: wait, count down, play, celebrate, reset, repeat. A state machine turns that loop into one enum, one `var`, and a handful of small async phase functions — each of which runs its phase, waits for its exit event, and returns the next phase. In this lesson you build the master state machine for the Coastal Race: five phases, event-driven transitions, and a `race` expression that gives the Racing phase two competing exits (finish line → Podium, storm timeout → Reset). It grows directly out of the enum seed you planted on South Shores.

Updated Examples verified on the live UEFN compiler

Overview

Every round-based game is secretly the same game: wait, count down, play, celebrate, clean up, repeat. The only thing that changes is the costume. In Treasure Cove, that loop wears a pirate hat — racers gather on the dock, a countdown booms across the water, boats tear through the channel, a winner takes the podium, and the cove resets for the next round.

A state machine is how you write that loop without drowning in booleans. Instead of var IsRacing, var IsCountingDown, var IsPodium (and the 2 a.m. bug where two of them are true at once), you keep one variable — the current phase — and let each phase be its own small async function that runs, waits for its exit event, and returns the next phase.

Back on South Shores, Coral seeded this idea with the enum-with-data pattern:

# The South Shores seed (enum-with-data-pattern):
# one enum, one var, switch behavior per phase.
shell_hunt_phase := enum:
    Lobby
    Hunting
    Celebration

That was three phases and synchronous handlers. Today Inkbeard drags the same seed into deeper water: five phases, async phase functions, and event-driven transitionsLobby → Countdown → Racing → Podium → Reset → Lobby. Same enum backbone, real game-loop muscle.

What you will build

This lesson builds the master state machine for the Coastal Race capstone — the conductor that will eventually orchestrate every other west-coves piece: the 3-2-1-GO countdown loop, the storm-vs-finish race expression, the custom events from prefer-events-over-polling, the checkpoint triggers, and the elimination flow.

Concretely, you will build one creative_device that:

  1. Defines a race_phase enum with five values (the enum seed, imported from South Shores' enum-with-data pattern).
  2. Runs a dispatcher loop: case on the current phase, call that phase's async function, store the phase it returns.
  3. Waits on events, not polling — a ready button signals the lobby exit, a finish-line trigger signals the racing exit.
  4. Uses a race expression so Racing has two exits: finish line → Podium, storm timeout → Reset.

Here is the full transition table you are about to implement. Keep it next to you — it is the design:

From Event / condition To
Lobby AllReady signaled (ready button pressed) Countdown
Countdown count reaches 0 Racing
Racing RacerFinished signaled (finish line crossed) Podium
Racing storm timeout elapses first Reset
Podium celebration timer elapses Reset
Reset cleanup delay done Lobby

Walkthrough

Step 1 — Name the phases

An enum gives every phase a name the compiler checks. Misspell race_phase.Racng and the build fails — a boolean soup would have silently shrugged.

Step 2 — One dispatcher, five phase functions

The engine of the machine is a loop around a case. Each arm calls one async phase function; each phase function does its work, suspends until its exit event, and returns the next phase. The dispatcher stores it and goes around again. No phase ever needs to know how the others work.

Step 3 — Bridge device events into async events

Device events arrive via .Subscribe on a plain (non-suspending) handler. Our phase functions live in the async world and want to .Await(). The bridge is a custom event(): the handler Signal()s it, the phase function Await()s it. (You met this glue in prefer-events-over-polling.)

Here is the complete device. Place it, wire ReadyButton to a button_device on the dock and FinishLine to a trigger_device across the channel mouth, and watch the log:

using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /Verse.org/Verse }

# The five phases of a Treasure Cove race round.
# Same enum seed as South Shores' shell_hunt_phase — just deeper water.
race_phase := enum:
    Lobby
    Countdown
    Racing
    Podium
    Reset

coastal_race_machine := class(creative_device):

    # Press to leave the Lobby. (The capstone swaps this for a real
    # everyone-ready check built with sync.)
    @editable
    ReadyButton : button_device = button_device{}

    # Crossing this trigger ends the race with a winner.
    @editable
    FinishLine : trigger_device = trigger_device{}

    # If nobody finishes before the storm, the round is abandoned.
    @editable
    StormTimeoutSeconds : float = 90.0

    # How long the winner gets to gloat.
    @editable
    PodiumSeconds : float = 8.0

    # Async bridges: device handlers Signal these, phases Await them.
    AllReady : event() = event(){}
    RacerFinished : event() = event(){}

    # THE state. One var. Never two phases at once.
    var Phase : race_phase = race_phase.Lobby

    OnBegin<override>()<suspends> : void =
        ReadyButton.InteractedWithEvent.Subscribe(OnReadyPressed)
        FinishLine.TriggeredEvent.Subscribe(OnFinishCrossed)
        RunMachine()

    # Device world -> async world.
    OnReadyPressed(Agent : agent) : void =
        AllReady.Signal()

    OnFinishCrossed(MaybeAgent : ?agent) : void =
        RacerFinished.Signal()

    # The dispatcher: forever, run the current phase, store the next one.
    RunMachine()<suspends> : void =
        loop:
            case (Phase):
                race_phase.Lobby     => set Phase = RunLobby()
                race_phase.Countdown => set Phase = RunCountdown()
                race_phase.Racing    => set Phase = RunRacing()
                race_phase.Podium    => set Phase = RunPodium()
                race_phase.Reset     => set Phase = RunReset()

    # LOBBY: wait for the ready signal. Nothing else can happen here.
    RunLobby()<suspends> : race_phase =
        Print("[Cove] Lobby open. Press the ready button to launch.")
        AllReady.Await()
        return race_phase.Countdown

    # COUNTDOWN: 3... 2... 1... GO. Exit condition is time, not an event.
    RunCountdown()<suspends> : race_phase =
        var Count : int = 3
        loop:
            Print("[Cove] {Count}...")
            Sleep(1.0)
            set Count -= 1
            if (Count <= 0):
                break
        Print("[Cove] GO!")
        return race_phase.Racing

    # RACING: two exits race each other. First one to happen wins;
    # the loser is cancelled automatically.
    RunRacing()<suspends> : race_phase =
        Print("[Cove] Racing! Finish line or storm — whichever comes first.")
        Winner := race:
            block:
                RacerFinished.Await()   # someone crossed the line
                1
            block:
                Sleep(StormTimeoutSeconds)  # the storm got there first
                2
        case (Winner):
            1 => return race_phase.Podium
            _ => return race_phase.Reset

    # PODIUM: fixed-length celebration, then tear down.
    RunPodium()<suspends> : race_phase =
        Print("[Cove] We have a winner! Podium time.")
        Sleep(PodiumSeconds)
        return race_phase.Reset

    # RESET: clean the cove, then reopen the lobby.
    RunReset()<suspends> : race_phase =
        Print("[Cove] Resetting the cove for the next round.")
        Sleep(2.0)
        return race_phase.Lobby

How to read it

Piece What it does
race_phase := enum: The five named states. The compiler rejects any phase that is not on this list.
var Phase : race_phase The single source of truth. There is no way for the machine to be in two phases at once.
RunMachine() The dispatcher: loop + case. Each arm runs one phase function and stores the phase it returns.
AllReady : event() = event(){} A recurring async event. Signal() wakes every task currently Await()ing it.
OnReadyPressedAllReady.Signal() The bridge from a device .Subscribe handler (non-suspending) into the async world.
race: inside RunRacing Two competing exits. The first arm to complete decides the next phase; the other arm is cancelled.
Winner := race: ... case (Winner): Each arm ends in an int tag; the case maps the winning tag to the next phase.

The quiet superpower: out-of-phase events are ignored for free. If a latecomer smashes the ready button during the race, AllReady.Signal() fires — but nothing is Await()ing it, so the signal simply evaporates. In the boolean-soup version you would need guard ifs in every handler. Here, the machine only listens for the exits of the phase it is actually in. Inkbeard calls this selective hearing, weaponized.

Common patterns

Pattern 1 — Entry and exit actions

Because each phase is a function, "do X when entering / leaving a phase" is just the first and last lines of that function. No extra framework needed:

using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }

phase_logger := class(creative_device):

    # Entry action, body, exit action — in one obvious place.
    RunPhase(Name : string, Seconds : float)<suspends> : void =
        Print("[enter] {Name}")     # entry action: play VFX, open doors...
        Sleep(Seconds)              # the phase body
        Print("[exit] {Name}")      # exit action: cleanup, stop music...

    OnBegin<override>()<suspends> : void =
        RunPhase("Lobby", 2.0)
        RunPhase("Countdown", 3.0)

Pattern 2 — Carrying data across a transition (the enum-with-data seed, grown up)

Enums name the phase but cannot carry a payload. The South Shores pattern's answer: keep the payload in a companion var set at the moment of transition. Here the finish-line handler stashes who won before signaling, so the podium phase knows whom to celebrate:

using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /Verse.org/Verse }

winner_tracker := class(creative_device):

    @editable
    FinishLine : trigger_device = trigger_device{}

    RacerFinished : event() = event(){}

    # The data riding along with the Racing -> Podium transition.
    var LastWinner : ?agent = false

    OnBegin<override>()<suspends> : void =
        FinishLine.TriggeredEvent.Subscribe(OnFinish)
        RacerFinished.Await()
        if (Winner := LastWinner?):
            Print("Podium time — we know exactly who to celebrate.")

    OnFinish(MaybeAgent : ?agent) : void =
        set LastWinner = MaybeAgent   # stash the payload first...
        RacerFinished.Signal()        # ...then fire the transition

Set the payload before you Signal() — the awaiting phase resumes immediately, and it should never wake up to stale data.

Where this goes next

This device is the skeleton of west-coves-coastal-race, the zone capstone. The capstone keeps this exact dispatcher and swaps each placeholder for the real piece you built earlier in the zone:

  • RunLobby absorbs detect-player-join (the roster) and parallel-execution-with-sync (the everyone-ready gate).
  • RunCountdown becomes the broadcast countdown from countdown-timer-loop.
  • RunRacing grows the per-racer monitors from launching-logic-with-spawn, checkpoint validation from trigger-device-checkpoints and failure-contexts-and-decides, DNF handling from elimination-event, and winner detection from rush-expressions.
  • The custom events gluing it all together are straight from prefer-events-over-polling, logged by the print-debugging RaceDebug logger, named by the naming-conventions standard.

One enum, one var, one dispatcher — and every prior lesson snaps into a phase. For the deep theory of <suspends>, race, and the dispatcher loop, the companion read is Concurrency in Verse: Async, race, and State Machines for Game Logic.

Build your own lesson with game_state_machine

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 →