You have spent seven lessons collecting parts on the seafloor: trigger plates, team loadouts, a first-person dive cam, relic arrays, leave-cleanup, and an air-supply timer. Today you bolt them together into one machine. This is CAPSTONE PHASE 1 of the Athenaeum Depths — the entire playable, device-driven dive round — and it is also a tour of the whole archipelago, because almost every pattern in it was forged in another zone first.
What you will build
A complete 2-team dive round:
- Divers drop through a moon-pool trigger gate, snap into first-person camera, and hunt relic plates scattered across the seafloor.
- Each relic plate grabbed adds to a per-player carried tally. Carried relics are worth nothing until they are banked on the surface barge plate.
- The whole dive runs inside an air-supply
race{}: bankRelicsToWinrelics beforeAirSecondsexpires, or the sea keeps whatever you still carry. - A four-state phase enum (
Lobby → Diving → Surfacing → Debrief) is the single source of truth; every handler checks it before acting. - Banked totals are folded into a persistent ledger (
weak_map(player, int)) that survives across sessions. - Tenders (the second team) hold the barge; if every diver runs out of respawns, the tenders win outright.
This is the playable core that Phase 2 (athenaeum-depths) will fuse with the scene-graph relic economy and the sequencer reveal.
Architecture at a glance
One device, sunken_dive_round, owns the round. Everything else is wiring:
DiveGate (trigger) ──► first-person cam AddTo
RelicPlates[] (triggers) ──► RelicsCarried[player] += 1
BankPlate (trigger) ──► RelicsBanked += carried, Signal RelicBankedEvent
│
RunRound(): Lobby ► Diving ► race{ air timer | banked-out } ► Surfacing ► Debrief
│
RelicLedger (module weak_map) ◄── banked totals persist across sessions
The round spine is the same start/play/win/reset orchestration you built in Professor AweShucks' custom-round-logic-using-verse — only the phases have grown teeth.
Walkthrough
Here is the full device. Read the section banners: each one names the zone that taught the pattern it uses.
using { /Fortnite.com/Devices }
using { /Fortnite.com/Game }
using { /Fortnite.com/Playspaces }
using { /Verse.org/Simulation }
using { /Verse.org/Random }
using { /UnrealEngine.com/Temporary/Diagnostics }
using { /UnrealEngine.com/Temporary/SpatialMath }
# The ship's ledger (east-volcano persistable pattern): total relics each
# player has EVER banked. A module-scoped weak_map keyed on player
# persists across sessions automatically — no save device required.
var RelicLedger : weak_map(player, int) = map{}
# The four phases of a dive round, in play order (the south-shores
# enum phase machine, back for its deepest job yet).
dive_phase := enum:
Lobby
Diving
Surfacing
Debrief
# The whole playable dive round, orchestrated by one device.
sunken_dive_round := class(creative_device):
# --- Trigger plates (deeps lesson: dive-trigger-volumes) ---
# Plate at the moon pool. Crossing it drops you into first person.
@editable
DiveGate : trigger_device = trigger_device{}
# One plate per sunken relic, placed on the seafloor.
@editable
RelicPlates : []trigger_device = array{}
# Plate on the surface barge where carried relics become banked.
@editable
BankPlate : trigger_device = trigger_device{}
# --- Team loadouts (deeps lesson: team-settings-and-inventory-device) ---
@editable
DiverLoadout : team_settings_and_inventory_device = team_settings_and_inventory_device{}
@editable
TenderLoadout : team_settings_and_inventory_device = team_settings_and_inventory_device{}
# --- First-person dive cam (deeps lesson: gameplay-camera-first-person-device) ---
@editable
DiveCam : gameplay_camera_first_person_device = gameplay_camera_first_person_device{}
# --- Presentation + round end ---
@editable
Announcer : hud_message_device = hud_message_device{}
@editable
RoundEnd : end_game_device = end_game_device{}
# --- Relic props (south-shores SpawnProp) ---
@editable
RelicAsset : creative_prop_asset = DefaultCreativePropAsset
@editable
RelicMarkers : []creative_prop = array{}
# --- Tuning ---
@editable
LobbySeconds : float = 15.0
# The air supply: the entire dive must fit inside this window.
@editable
AirSeconds : float = 90.0
@editable
RelicsToWin : int = 3
# --- Round state ---
# The single source of truth for the whole round.
var CurrentPhase : dive_phase = dive_phase.Lobby
# Every relic prop spawned this round, so we can Dispose them later.
var SpawnedRelics : []creative_prop = array{}
# Relics grabbed below but not yet banked. Lost if the air runs out.
var RelicsCarried : [player]int = map{}
# Relics safely banked on the barge this round.
var RelicsBanked : [player]int = map{}
# Fires every time someone banks (west-coves events-over-polling).
RelicBankedEvent : event() = event(){}
# Wraps a plain string so message-typed parameters accept it.
Msg<localizes>(S : string) : message = "{S}"
OnBegin<override>()<suspends> : void =
DiveCam.Enable()
DiveGate.TriggeredEvent.Subscribe(OnDiveGate)
BankPlate.TriggeredEvent.Subscribe(OnBankPlate)
# If every diver runs out of respawns, the tenders hold the barge.
DiverLoadout.TeamOutOfRespawnsEvent.Subscribe(OnDiversWiped)
# Leave-cleanup (deeps lesson 5): never count a ghost diver.
GetPlayspace().PlayerRemovedEvent().Subscribe(OnPlayerLeft)
RunRound()
# ---------- The round spine (center-village round-logic shape) ----------
RunRound()<suspends> : void =
EnterPhase(dive_phase.Lobby)
Sleep(LobbySeconds)
SeedTallies()
SpawnRelics()
WatchRelicPlates()
EnterPhase(dive_phase.Diving)
# west-coves race{}: the dive ends when EITHER the air runs out
# OR someone banks enough relics. First branch to finish wins;
# the loser is cancelled on the spot.
race:
block:
Sleep(AirSeconds)
Announcer.Show(Msg("Air spent! The sea keeps whatever you still carry."), ?DisplayTime := 5.0)
AwaitBankedOut()
EnterPhase(dive_phase.Surfacing)
DiveCam.RemoveFromAll()
BankRoundIntoLedger()
CleanupRelics()
Sleep(3.0)
EnterPhase(dive_phase.Debrief)
CrownChampion()
# ---------- Phase machine (south-shores enum-with-data pattern) ----------
# ONE doorway for every phase change: reassign, then react.
EnterPhase(NewPhase : dive_phase) : void =
set CurrentPhase = NewPhase
Announcer.Show(Msg(PhaseLabel(NewPhase)), ?DisplayTime := 4.0)
Print("Phase is now: {PhaseLabel(NewPhase)}")
# Each phase's on-screen banner text — the data riding on the enum.
PhaseLabel(Phase : dive_phase) : string =
case (Phase):
dive_phase.Lobby => "Suit up - the dive begins soon"
dive_phase.Diving => "DIVE! Bank {RelicsToWin} relics before your air runs out"
dive_phase.Surfacing => "Surfacing - tallying the haul"
dive_phase.Debrief => "Debrief - crowning the deep champion"
_ => "Unknown phase"
# ---------- Setup ----------
SeedTallies() : void =
for (P : GetPlayspace().GetPlayers()):
if (set RelicsCarried[P] = 0) {}
if (set RelicsBanked[P] = 0) {}
SpawnRelics() : void =
for (Marker : Shuffle(RelicMarkers)):
# SpawnProp returns tuple(?creative_prop, spawn_prop_result).
Result := SpawnProp(RelicAsset, Marker.GetTransform())
if (Relic := Result(0)?):
set SpawnedRelics += array{Relic}
Print("{SpawnedRelics.Length} relics rest in the silt.")
WatchRelicPlates() : void =
for (Plate : RelicPlates):
Plate.Enable()
spawn{ WatchRelicPlate(Plate) }
# One async watcher per plate, so we know WHICH plate fired —
# a Subscribe handler only receives the ?agent, never the device.
WatchRelicPlate(Plate : trigger_device)<suspends> : void =
MaybeAgent := Plate.TriggeredEvent.Await()
Plate.Disable() # one relic per plate
if:
Agent := MaybeAgent?
P := player[Agent]
CurrentPhase = dive_phase.Diving
Carried := RelicsCarried[P]
set RelicsCarried[P] = Carried + 1
then:
Announcer.Show(Agent, Msg("Relic grabbed! Carrying {Carried + 1} - get it to the barge."), ?DisplayTime := 3.0)
# ---------- Trigger handlers (dive-trigger-volumes) ----------
# Crossing the dive gate mid-round pushes the first-person cam.
OnDiveGate(MaybeAgent : ?agent) : void =
if (Agent := MaybeAgent?, CurrentPhase = dive_phase.Diving):
DiveCam.AddTo(Agent)
# The bank plate converts carried relics into banked relics.
OnBankPlate(MaybeAgent : ?agent) : void =
if:
Agent := MaybeAgent?
P := player[Agent]
CurrentPhase = dive_phase.Diving
Carried := RelicsCarried[P]
Carried > 0
Banked := RelicsBanked[P]
set RelicsBanked[P] = Banked + Carried
set RelicsCarried[P] = 0
then:
DiveCam.RemoveFrom(Agent) # back to third person on deck
Announcer.Show(Agent, Msg("Banked {Banked + Carried} relics!"), ?DisplayTime := 3.0)
RelicBankedEvent.Signal()
# Completes only when some diver has banked enough. If nobody ever
# does, the air-timer branch of the race cancels this loop.
AwaitBankedOut()<suspends> : void =
loop:
RelicBankedEvent.Await()
Best := BestBanked()
if (Best >= RelicsToWin):
break
BestBanked() : int =
var Best : int = 0
for (P -> Count : RelicsBanked, Count > Best):
set Best = Count
return Best
# ---------- Wrap-up ----------
# Fold this round's haul into the persistent ledger (east-volcano).
BankRoundIntoLedger() : void =
for (P -> Count : RelicsBanked, Count > 0):
var Total : int = Count
if (Previous := RelicLedger[P]):
set Total = Previous + Count
if (set RelicLedger[P] = Total) {}
Print("Ledger updated: {Total} relics banked all-time.")
# Dispose every relic prop still on the seafloor (south-shores).
CleanupRelics() : void =
for (Relic : SpawnedRelics, Relic.IsValid[]):
Relic.Dispose()
set SpawnedRelics = array{}
CrownChampion() : void =
var MaybeChampion : ?player = false
var BestCount : int = 0
for (P -> Count : RelicsBanked, Count > BestCount):
set MaybeChampion = option{P}
set BestCount = Count
if (Champion := MaybeChampion?):
Announcer.Show(Msg("Deep champion! {BestCount} relics banked."), ?DisplayTime := 5.0)
RoundEnd.Activate(Champion)
else:
Announcer.Show(Msg("The sea keeps its treasure. Nobody banked a relic."), ?DisplayTime := 5.0)
# ---------- Lifecycle edges ----------
OnDiversWiped() : void =
Print("All divers down - the tenders hold the barge.")
TenderLoadout.EndRound()
# deeps lesson 5: sweep a leaver out of every tally so ghost
# entries never skew BestBanked or the champion pick.
OnPlayerLeft(Leaver : player) : void =
set RelicsCarried = RemoveKey(RelicsCarried, Leaver)
set RelicsBanked = RemoveKey(RelicsBanked, Leaver)
Print("A diver left the water. Tallies swept clean.")
RemoveKey(Source : [player]int, Gone : player) : [player]int =
var Rebuilt : [player]int = map{}
for (P -> Count : Source, P <> Gone):
if (set Rebuilt[P] = Count) {}
return Rebuilt
Step through the load-bearing choices:
- The phase enum is law. Every trigger handler starts with
CurrentPhase = dive_phase.Divingin its failure context. A plate stepped on duringDebriefdoes nothing. This is the south-shores enum machine doing exactly what it was built for: transitions happen in ONE place (EnterPhase), and everything else just reads. - The air supply is a
race{}. One branch sleeps forAirSeconds; the other (AwaitBankedOut) loops onRelicBankedEvent.Await()until somebody's banked count reachesRelicsToWin. Whichever finishes first cancels the other — Inkbeard's west-coves timeout pattern, verbatim. No polling loop, no flag-checking every frame. - One
spawn{}watcher per relic plate. ASubscribehandler only receives the?agent— it cannot tell you which plate fired. Awaiting each plate in its own spawned task gives every watcher its ownPlatebinding, so it canDisable()exactly the plate that was claimed. - Carried is not banked.
RelicsCarriedandRelicsBankedare separate[player]intmaps on purpose: the air timer wipes carried relics (they were never banked), which is the entire risk/reward tension of the mode. - The camera follows the fiction.
DiveGatepushes the first-person cam onto the crossing agent; the bank plate pops it (you are on deck now);RemoveFromAll()atSurfacingcatches anyone the timer dragged up. - Leavers are swept, not ignored.
PlayerRemovedEventrebuilds both tally maps without the leaver — the deeps lesson-5 habit. Skip this and a ghost entry can win your round. - The ledger outlives the round.
RelicLedgeris a module-scopedweak_map(player, int)— Cinder's east-volcano persistable pattern.BankRoundIntoLedgerfolds each round's haul in, and it is still there next session.
Device wiring checklist
In the UEFN editor, before you press Launch Session:
- [ ] DiveGate — trigger_device at the moon pool / dive-hole entry. Triggered by Player, unlimited triggers.
- [ ] RelicPlates — one trigger_device per relic spot on the seafloor. Set Enabled at Game Start: No (Verse enables them when the dive starts) and place each one on a
RelicMarkersprop so the spawned relic visually sits on its plate. - [ ] BankPlate — trigger_device on the barge deck, unlimited triggers.
- [ ] DiverLoadout — team_settings_and_inventory_device on Team 1 (divers): grant fins/harpoon loadout, set spawn limits here.
- [ ] TenderLoadout — second device on Team 2 (tenders): barge-keeper loadout.
- [ ] DiveCam — gameplay_camera_first_person_device, defaults are fine.
- [ ] Announcer — hud_message_device.
- [ ] RoundEnd — end_game_device with Activating Team enabled, so
Activate(Champion)ends the round in the champion's team's favor. - [ ] RelicAsset / RelicMarkers — your relic prop asset plus one placed marker prop per plate.
- [ ] Island settings: 2 teams, team rotation off, round time comfortably above
LobbySeconds + AirSeconds.
Imported from across the archipelago
This lesson is deliberately a reunion. What you are reusing, and where it was forged:
| Pattern in this device | Zone that taught it |
|---|---|
race { Sleep(AirSeconds) … } timeout + event().Await() over polling + spawn{} watchers |
west-coves async module (Inkbeard's trench) |
dive_phase enum + EnterPhase single doorway + PhaseLabel case data |
south-shores enum-with-data phase machine (Coral's beach) |
SpawnProp / IsValid[] / Dispose relic rounds |
south-shores spawn-prop |
RunRound() start/play/win/reset spine |
center-village custom-round-logic module (Professor AweShucks) |
Module-scoped weak_map(player, int) persistent ledger |
east-volcano persistable pattern (Cinder) |
| Trigger plates, team loadouts, first-person cam, leave-cleanup | the-deeps lessons 4–7 |
Common patterns
A low-air warning as a third race branch. A branch that never finishes can still do work along the way — a classic west-coves trick for layered timers:
# Give divers a shrinking-air warning without touching the round spine:
# run the countdown as a second race branch that never wins.
race:
block:
Sleep(AirSeconds)
Announcer.Show(Msg("Air spent!"), ?DisplayTime := 5.0)
AwaitBankedOut()
block:
# Warning track: fires at 2/3 air, then keeps waiting forever
# so it can never be the branch that ends the race.
Sleep(AirSeconds * 0.66)
Announcer.Show(Msg("Air is running low - surface soon!"), ?DisplayTime := 4.0)
Sleep(Inf)
Team victory instead of an individual champion. team_settings_and_inventory_device.EndRound() declares that device's team the winner, which suits a co-op dive better than crowning one diver:
# Prefer a team victory over an individual champion? Let the loadout
# device end the round for the whole diver team instead:
CrownDiverTeam() : void =
if (BestBanked() >= RelicsToWin):
# EndRound on the team's own settings device declares that
# team the winner - no end_game_device needed.
DiverLoadout.EndRound()
else:
TenderLoadout.EndRound()
Where this goes next
This device IS Phase 1 of the zone capstone. athenaeum-depths (the-deeps lesson 21) imports it wholesale and fuses it with everything the scene-graph half of the zone teaches: the relic props become scene-graph entities with glow/sound components, banking feeds a relic economy, and a sequencer + data-layer reveal opens the inner Athenaeum when the ledger crosses its threshold. Keep this file clean — future-you is its next importer.
And your ledger already persists, so every dive from here on is banking toward that door.