Twenty-two lessons ago you were printing debug lines on a beach. Today you ship a game. The Coastal Race is West Coves' capstone: a full multiplayer boat race where players wash into the Marooned Town lobby dock, grab boats, and tear through a checkpoint course in Mangrove Channel while Inkbeard's kraken patrols for stragglers and a storm chews the map behind them. Every mechanic in this build is a lesson you already passed — this article is where they become one machine.
What you will build
A complete, replayable race mode driven by one master state machine:
Lobby -> Countdown -> Racing -> Podium -> Reset -> (loop)
Architecture at a glance:
- One orchestrator device (
coastal_race_device) owns the phase loop and the roster. Devices do what devices do best (spawn boats, count laps, patrol the channel); Verse coordinates them. - A constants module (
RaceConfig) holds every tunable — the North Jungle module habit. - A custom event bus (
RosterChanged,RacerFinished) lets phases await changes instead of polling for them. Inkbeard has eight arms and zero patience for polling loops. - A
race{}expression pits the whole fleet against the storm timeout — the first branch to finish cancels the other. - A module-scoped
weak_map(player, int)persists career wins across sessions, East Volcano style.
Build-review checklist — the 10 mechanics and the lesson that taught each one. Walk this list when you review your build; if a row feels shaky, replay that lesson before you ship:
| # | Mechanic | Taught in |
|---|---|---|
| 1 | Debug overlay + hidden verbose toggle | print-debugging-in-verse (L1) |
| 2 | RaceConfig constants module |
constants (L3) + shared-helper-module (L8) |
| 3 | Safe unwrap of FinishOrder[0] winner |
return-an-option (L4) + array-bounds-are-failable (L6) |
| 4 | Roster add/remove (rebuild-without) | array-add-remove (L7) |
| 5 | 3-2-1-GO Sleep-loop countdown | countdown-timer-loop (L9) |
| 6 | Storm timeout race{} |
race-expressions (L12) |
| 7 | Event-driven lobby (no polling) | prefer-events-over-polling (L15) |
| 8 | Mid-session joins + roster seeding | detect-player-join (L17) |
| 9 | Elimination -> spectator camera push | elimination-event (L18) + first-person-spectator-camera (L21) |
| 10 | Phase enum state machine + round loop | simple-game-state-machine (L19) + concurrency-game-timing (L14) |
The kraken itself is your npc-state-machine-behavior (L20) NPC — the orchestrator only enables/disables its spawner. The checkpoint gates' glow pulse is your async-scene-graph-component (L22) prefab, wired in the editor.
Walkthrough
Step 1 — Place the hardware
Lay out the course in UEFN first: a line of Boat Spawners along the Marooned Town pier (one per racer slot), Race Checkpoint gates threading Mangrove Channel, one Race Manager, an NPC Spawner in the cove with your kraken behavior attached, a First Person Gameplay Camera, a HUD Message device, and one hidden Trigger behind the bait shack for the debug toggle.
Device sidebars if you need a refresher on any of them: Race Checkpoint, Race Manager, and Timer all have full member-surface guides.
Step 2 — The whole machine
This is the complete mode. Read the phase functions top to bottom — each one is a lesson you have already built.
using { /Fortnite.com/Devices }
using { /Fortnite.com/Characters }
using { /Fortnite.com/Vehicles }
using { /Fortnite.com/Playspaces }
using { /Fortnite.com/Game }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# = Persistent wins (East Volcano's persistable pattern) ==============
# A module-scoped var weak_map(player, int) IS the save file: UEFN keeps
# one int per player, per island, across sessions. No device needed.
var CoastalRaceWins:weak_map(player, int) = map{}
# = RaceConfig (North Jungle's shared-module habit) ====================
# Every tunable lives in one module. Balancing the race never means
# hunting magic numbers through phase functions.
RaceConfig<public> := module:
MinRacers<public>:int = 2
CountdownSeconds<public>:int = 3
StormTimeoutSeconds<public>:float = 240.0
PodiumSeconds<public>:float = 8.0
ResetSeconds<public>:float = 4.0
# = Game phases (South Shores' enum seed, grown into a state machine) =
race_phase := enum{Lobby, Countdown, Racing, Podium, Reset}
# = The whole minigame in one device ===================================
coastal_race_device := class(creative_device):
# -- Fleet: one boat dock per racer slot, in order along the pier.
@editable
BoatDocks : []vehicle_spawner_boat_device = array{}
# -- Course: the checkpoint gates through Mangrove Channel.
@editable
Checkpoints : []race_checkpoint_device = array{}
# -- The race brain: lap counting + finish detection.
@editable
RaceManager : race_manager_device = race_manager_device{}
# -- Inkbeard's pet: an NPC spawner patrolling Kraken's Cove.
@editable
KrakenSpawner : npc_spawner_device = npc_spawner_device{}
# -- Eliminated racers watch the rest of the race through this.
@editable
SpectatorCam : gameplay_camera_first_person_device = gameplay_camera_first_person_device{}
# -- Countdown, feed, and podium announcements (South Shores HUD habit).
@editable
Announcer : hud_message_device = hud_message_device{}
# -- Hidden admin trigger: toggles verbose race-state logging.
@editable
DebugTrigger : trigger_device = trigger_device{}
# -- Live state --
var Phase : race_phase = race_phase.Lobby
var Racers : []player = array{}
var FinishOrder : []player = array{}
var Verbose : logic = false
# -- Custom event bus (North Jungle): phases await signals, never poll.
RosterChanged : event() = event(){}
RacerFinished : event() = event(){}
# message params need localized text, not raw strings.
Msg<localizes>(S:string):message = "{S}"
OnBegin<override>()<suspends>:void =
Playspace := GetPlayspace()
# Roster wiring: joins and leaves.
Playspace.PlayerAddedEvent().Subscribe(OnPlayerAdded)
Playspace.PlayerRemovedEvent().Subscribe(OnPlayerRemoved)
# Race wiring: finishes, checkpoint feed, boat launches.
RaceManager.RaceCompletedEvent.Subscribe(OnRacerFinishedRace)
for (Gate : Checkpoints):
Gate.CheckpointCompletedEvent.Subscribe(OnCheckpointPassed)
for (Dock : BoatDocks):
Dock.SpawnedEvent.Subscribe(OnBoatSpawned)
# Debug wiring: a hidden trigger flips verbose logging.
DebugTrigger.TriggeredEvent.Subscribe(OnDebugToggle)
# Players already on the island when the script starts.
for (Existing : Playspace.GetPlayers()):
RegisterRacer(Existing)
# The master round loop (Center Village's round-logic pattern).
loop:
AwaitLobby()
DoCountdown()
DoRacing()
DoPodium()
DoReset()
# -- Phase 1: LOBBY - event-driven wait, no polling ----------------
AwaitLobby()<suspends>:void =
SetPhase(race_phase.Lobby)
Announcer.Show(Msg("Racers wanted at the Marooned Town dock!"))
loop:
if (Racers.Length >= RaceConfig.MinRacers):
break
RosterChanged.Await()
# -- Phase 2: COUNTDOWN - boats out, synced 3-2-1-GO ---------------
DoCountdown()<suspends>:void =
SetPhase(race_phase.Countdown)
for (Index -> Racer : Racers):
# Failable index: more racers than docks fails safely.
if (Dock := BoatDocks[Index]):
Dock.Enable()
Dock.RespawnVehicle()
Dock.AssignDriver(Racer)
Sleep(1.5) # let the hulls settle on the water
var Count : int = RaceConfig.CountdownSeconds
loop:
if (Count <= 0):
break
Announcer.Show(Msg("{Count}..."))
Sleep(1.0)
set Count = Count - 1
Announcer.Show(Msg("GO!"))
# -- Phase 3: RACING - the fleet vs. the storm ---------------------
DoRacing()<suspends>:void =
SetPhase(race_phase.Racing)
set FinishOrder = array{}
KrakenSpawner.Enable()
KrakenSpawner.Spawn()
RaceManager.Enable()
RaceManager.Begin()
# THE capstone line: first branch to finish cancels the other.
race:
AwaitAllFinished()
StormCountdown()
RaceManager.End()
KrakenSpawner.DespawnAll(false)
KrakenSpawner.Disable()
AwaitAllFinished()<suspends>:void =
loop:
if (Racers.Length > 0, FinishOrder.Length >= Racers.Length):
break
RacerFinished.Await()
StormCountdown()<suspends>:void =
Sleep(RaceConfig.StormTimeoutSeconds)
Announcer.Show(Msg("The storm swallows Mangrove Channel!"))
# -- Phase 4: PODIUM - crown the winner, persist the win -----------
DoPodium()<suspends>:void =
SetPhase(race_phase.Podium)
if (Winner := FinishOrder[0]):
var CareerWins : int = 1
if (Past := CoastalRaceWins[Winner]):
set CareerWins = Past + 1
# Map-set is failable - the empty block satisfies the context.
if (set CoastalRaceWins[Winner] = CareerWins) {}
Announcer.Show(Winner, Msg("Champion of the Coves! Career wins: {CareerWins}"))
Sleep(RaceConfig.PodiumSeconds)
# -- Phase 5: RESET - clean the water, loop back to lobby ----------
DoReset()<suspends>:void =
SetPhase(race_phase.Reset)
for (Racer : Racers):
SpectatorCam.RemoveFrom(Racer)
for (Dock : BoatDocks):
Dock.DestroyVehicle()
Dock.Disable()
Sleep(RaceConfig.ResetSeconds)
# -- Roster management ----------------------------------------------
OnPlayerAdded(NewPlayer:player):void =
if (Phase = race_phase.Lobby):
RegisterRacer(NewPlayer)
else:
# Mid-race joiners spectate until the next lobby.
SpectatorCam.AddTo(NewPlayer)
OnPlayerRemoved(Gone:player):void =
RemoveRacer(Gone)
RegisterRacer(NewPlayer:player):void =
if (not ListHas[Racers, NewPlayer]):
set Racers = Racers + array{NewPlayer}
if (FC := NewPlayer.GetFortCharacter[]):
# Kraken strikes and storm damage both land here.
FC.EliminatedEvent().Subscribe(OnRacerEliminated)
RosterChanged.Signal()
RemoveRacer(Target:player):void =
# Rebuild-without: the failable filter comparison doubles as
# the "keep everyone else" test.
var Kept : []player = array{}
for (P : Racers, P <> Target):
set Kept = Kept + array{P}
set Racers = Kept
RosterChanged.Signal()
RacerFinished.Signal() # fewer racers may mean the race is done
ListHas(List:[]player, Candidate:player)<decides><transacts>:void =
List.Find[Candidate]
# -- Race events ------------------------------------------------------
OnRacerFinishedRace(Finisher:agent):void =
if (FinisherP := player[Finisher], not ListHas[FinishOrder, FinisherP]):
set FinishOrder = FinishOrder + array{FinisherP}
RacerFinished.Signal()
OnCheckpointPassed(Racer:agent):void =
Announcer.Show(Racer, Msg("Checkpoint!"))
if (Verbose?):
Print("RaceDebug: a racer cleared a gate")
OnRacerEliminated(Result:elimination_result):void =
Victim := Result.EliminatedCharacter
if (Agent := Victim.GetAgent[], VictimP := player[Agent]):
RemoveRacer(VictimP)
# Push the spectator camera onto their stack.
SpectatorCam.AddTo(VictimP)
Announcer.Show(VictimP, Msg("The kraken claims another... enjoy the view."))
OnBoatSpawned(Boat:fort_vehicle):void =
if (Verbose?):
Print("RaceDebug: a hull hit the water")
OnDebugToggle(MaybeAgent:?agent):void =
if (Verbose?):
set Verbose = false
Print("RaceDebug: verbose logging OFF")
else:
set Verbose = true
Print("RaceDebug: verbose logging ON")
SetPhase(NewPhase:race_phase):void =
set Phase = NewPhase
if (Verbose?):
Print("RaceDebug: phase changed")```
### Step 3 — How the pieces click
- **The lobby awaits, never polls.** `AwaitLobby` checks the roster only when `RosterChanged` fires. Joins, leaves, and eliminations all signal it.
- **Boat assignment is failable on purpose.** `BoatDocks[Index]` inside the `if` means a fifth racer at a four-dock pier simply waits for the next round instead of crashing the script.
- **`race:` is the whole drama.** If every racer finishes, `AwaitAllFinished` completes and the storm branch is cancelled mid-`Sleep`. If the storm timer runs out first, the finish-await is cancelled and the round ends with whoever already crossed.
- **Eliminations shrink the goal.** `RemoveRacer` signals `RacerFinished` too, so when the kraken eats the last racer still on the water, `AwaitAllFinished` re-checks and the round closes — no stuck rounds.
- **Wins survive the session.** `CoastalRaceWins` is module-scoped, so UEFN persists it per player. The podium reads the old count, writes the new one, and announces the career total.
- **The missing-import trap.** `OnBoatSpawned` receives a `fort_vehicle` — that type lives in `/Fortnite.com/Vehicles`. Forget that `using` and you get `Unknown identifier 'fort_vehicle'` at compile time. It is the single most common error in boat-race builds, and now you know the fix before the compiler tells you.
### Step 4 — Device wiring checklist
In the `coastal_race_device` Details panel:
1. **BoatDocks** — add every pier Boat Spawner, in dock order. Set each spawner's respawn behavior to *destroy previous vehicle*.
2. **Checkpoints** — add every Race Checkpoint gate in course order; mark the last one as the finish in its own options and give each its order number.
3. **RaceManager** — the placed Race Manager; match its lap count to `RaceConfig`.
4. **KrakenSpawner** — the NPC Spawner in Kraken's Cove, with your patrol/chase/strike `npc_behavior` set as its character definition.
5. **SpectatorCam** — the First Person camera. Leave it added to nobody; Verse pushes it.
6. **Announcer** — the HUD Message device. Set display time around 2.0s so countdown beats replace each other cleanly.
7. **DebugTrigger** — the hidden trigger. Set it to *not* visible in game unless you *want* players finding your admin switch (Inkbeard found ours in a day).
8. **Island settings** — spawn players as they join, respawn on elimination disabled during rounds (eliminated racers spectate until Reset).
## Imports from other zones
This capstone deliberately reuses habits and modules you built elsewhere on the island:
- **North Jungle** — the `RaceConfig` module pattern (`verse-modules`, `creating-custom-modules`) and the custom `event()` bus (`verse-events-custom`).
- **South Shores** — the HUD announcement habit (`hud-message-feedback`, `string-interpolation-hud`), the phase-enum seed (`enum-with-data-pattern`), and `SpawnProp` (`spawn-prop`) if you dress the podium with runtime props and floating debris.
- **Center Village** — the start/play/win/reset round-loop shape (`custom-round-logic-using-verse`).
- **East Volcano** — the persistable `weak_map(player, int)` save pattern (`persisting-a-tycoon-state-with-persistable`).
## Common patterns
### Ordered-gate validation with raw triggers
The Race Checkpoint device enforces ordering natively. When you build gates from plain triggers (or your L22 scene-graph prefab), enforce order yourself with a failable validator — `<decides><transacts>` rolls the map write back if any check fails:
```verse
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# Ordered-gate validation with raw triggers: ValidateGate[] only
# succeeds when THIS gate is exactly the racer's next one.
gate_validator_device := class(creative_device):
@editable
GateA : trigger_device = trigger_device{}
@editable
GateB : trigger_device = trigger_device{}
var Progress : [player]int = map{}
OnBegin<override>()<suspends>:void =
GateA.TriggeredEvent.Subscribe(OnGateA)
GateB.TriggeredEvent.Subscribe(OnGateB)
OnGateA(MaybeAgent:?agent):void = HandleGate(MaybeAgent, 0)
OnGateB(MaybeAgent:?agent):void = HandleGate(MaybeAgent, 1)
HandleGate(MaybeAgent:?agent, GateIndex:int):void =
if (Agent := MaybeAgent?, Racer := player[Agent], ValidateGate[Racer, GateIndex]):
Print("Gate cleared, in order.")
else:
Print("Out of order - no shortcuts past Inkbeard.")
# Failure rolls back the map write too - <transacts> undoes it all.
ValidateGate(Racer:player, GateIndex:int)<decides><transacts>:void =
var Expected : int = 0
if (Prior := Progress[Racer]):
set Expected = Prior + 1
Expected = GateIndex
set Progress[Racer] = GateIndex
Storm clock on a timer_device
Prefer designer-tunable timeouts? Swap StormCountdown's Sleep for a Timer device and await its FailureEvent through an event bridge:
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# Storm clock driven by a timer_device instead of Sleep(): designers
# tune the timeout on the device, Verse just awaits the signal.
storm_clock_device := class(creative_device):
@editable
StormTimer : timer_device = timer_device{}
StormBroke : event() = event(){}
OnBegin<override>()<suspends>:void =
StormTimer.FailureEvent.Subscribe(OnStormHit)
StormTimer.Start()
StormBroke.Await()
Print("The storm wall reached the finish line - race over.")
OnStormHit(MaybeAgent:?agent):void =
StormBroke.Signal()
Where this goes next
Ship it. Playtest with a full lobby, watch the debug log through one full Lobby-to-Reset cycle, and confirm a second session still shows career wins on the podium — that line is your persistence proof.
Then two exports leave the coves with you:
- The CheckpointGate prefab (your L22 async scene-graph component) sails on to The Deeps, where the dive-round treasure gates import it unchanged.
CoastalRaceWinstravels back to AweShucks Town's matchmaking portal, where Center Village's hub reads it for bragging rights and race-ladder gating.
The game was the curriculum all along. Welcome to the far side of the island, captain.