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 transitions — Lobby → 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:
- Defines a
race_phaseenum with five values (the enum seed, imported from South Shores' enum-with-data pattern). - Runs a dispatcher loop:
caseon the current phase, call that phase's async function, store the phase it returns. - Waits on events, not polling — a ready button signals the lobby exit, a finish-line trigger signals the racing exit.
- Uses a
raceexpression 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. |
OnReadyPressed → AllReady.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:
RunLobbyabsorbs detect-player-join (the roster) and parallel-execution-with-sync (the everyone-ready gate).RunCountdownbecomes the broadcast countdown from countdown-timer-loop.RunRacinggrows 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.