Overview
Down in the Deeps, every round of the Sunken Dive is a ledger problem. Relics move from the RelicLedger (the array of un-banked relic IDs still in play) into a diver's carried stash, and only become score when they surface and bank. Which means the most dangerous player on your island is not the kraken — it's the diver who disconnects with three relics in their pockets. Their [player]-keyed map entries go stale, the relics vanish from the economy, and the first scoring pass that touches the ghost entry either miscounts or fails outright.
The real Verse API for the goodbye is fort_playspace.PlayerRemovedEvent() — a function returning a listenable(player) that fires whenever a human player leaves your island. You met the gentle version of this on South Shores (round fairness for Shell-Hunt). This lesson is the Deeps extension: safe mid-round cleanup of per-player map and array state, built on the option-returning lookup pattern you learned in West Coves.
What you will build
The mid-round disconnect handler for the Athenaeum Depths capstone — the piece that guarantees a leaver's relics return to the ledger. One creative_device that:
- Holds the round economy:
RelicLedger : []int(relics in play),CarriedRelics : [player][]int(un-banked, per diver),BankedCounts : [player]int(safe score). - Subscribes to
GetPlayspace().PlayerRemovedEvent()andPlayerAddedEvent()so the maps always mirror the real roster — including late joiners. - Imports the West Coves
return-an-optionpattern: aTakeCarried(Diver):?[]inthelper that returns the diver's carried relics as an option and removes their map entry in one motion — the same helper serves both normal banking and dropout cleanup. - On a leave, runs cleanup in order: un-banked relics → back to the ledger; map entries → removed; roster → reported so the round logic never waits on a ghost.
Walkthrough
Step 1 — Place the devices
In UEFN, place a HUD Message device with its message set to something like "Lost relics drift back into the deep...". That's the only editor wiring this lesson needs — the playspace events come from Verse itself, not from a placed device.
Step 2 — The complete dropout handler
Create a new Verse file, paste this in, build, and wire the @editable slot in the Details panel:
using { /Fortnite.com/Devices }
using { /Fortnite.com/Playspaces }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# The Deeps dropout handler: when a diver vanishes mid-round,
# their un-banked relics return to the ledger and their map
# entries are cleaned up -- no ghosts, no lost relics.
relic_dropout_device := class(creative_device):
# HUD Message device announcing relics drifting back into play.
@editable
DropoutMessage : hud_message_device = hud_message_device{}
# The RelicLedger: relic IDs still in play, waiting to be found.
var RelicLedger : []int = array{}
# Per-diver UN-BANKED relics (picked up, not yet surfaced with).
var CarriedRelics : [player][]int = map{}
# Per-diver banked score -- safe once they surface.
var BankedCounts : [player]int = map{}
OnBegin<override>()<suspends>:void =
# Seed the round: five relics hidden in the Athenaeum vaults.
set RelicLedger = array{101, 102, 103, 104, 105}
# Every diver already in the playspace gets ledger entries.
for (Diver : GetPlayspace().GetPlayers()):
InitDiver(Diver)
# Playspace lifecycle events use () before .Subscribe
# (they are functions returning a listenable, unlike device events).
GetPlayspace().PlayerAddedEvent().Subscribe(OnDiverJoined)
GetPlayspace().PlayerRemovedEvent().Subscribe(OnDiverLeft)
Print("Ledger seeded with {RelicLedger.Length} relics.")
# Fresh entries for a diver -- used at OnBegin and for late joiners.
InitDiver(Diver : player):void =
if (set CarriedRelics[Diver] = array{}) {}
if (set BankedCounts[Diver] = 0) {}
OnDiverJoined(NewDiver : player):void =
InitDiver(NewDiver)
Print("A new diver slips beneath the surface.")
# WEST-COVES IMPORT (return-an-option): instead of letting every
# caller poke the map, this helper RETURNS ?[]int -- the diver's
# carried relics if they had an entry, or false if not -- and
# removes their entry from the map in the same motion.
TakeCarried(Diver : player)<transacts>:?[]int =
var Result : ?[]int = false
if (Relics := CarriedRelics[Diver]):
set Result = option{Relics}
# Verse maps have no delete -- rebuild without the diver.
var Rebuilt : [player][]int = map{}
for (Key -> Value : CarriedRelics, Key <> Diver):
if (set Rebuilt[Key] = Value) {}
set CarriedRelics = Rebuilt
Result
# Called by the round logic when a diver grabs a relic:
# move the ID from the ledger into their carried stash.
PickUpRelic(Diver : player, RelicID : int):void =
var Remaining : []int = array{}
for (ID : RelicLedger, ID <> RelicID):
set Remaining += array{ID}
set RelicLedger = Remaining
if (Carried := CarriedRelics[Diver]):
if (set CarriedRelics[Diver] = Carried + array{RelicID}) {}
# Called when a diver surfaces: carried relics become banked score.
# Note it REUSES TakeCarried -- same option-safe helper as cleanup.
BankRelics(Diver : player):void =
if (Relics := TakeCarried(Diver)?, Count := BankedCounts[Diver]):
if (set BankedCounts[Diver] = Count + Relics.Length) {}
# Still on the island, so re-seed an empty carried entry.
if (set CarriedRelics[Diver] = array{}) {}
Print("Banked {Relics.Length} relics.")
# THE cleanup handler. Order matters:
# 1) un-banked relics back to the ledger, 2) drop their map
# entries, 3) report the surviving roster.
OnDiverLeft(Leaver : player):void =
# Step 1: option-safe take. If the leaver never had an entry
# this fails quietly and we skip straight to step 2.
if (LostRelics := TakeCarried(Leaver)?):
for (RelicID : LostRelics):
set RelicLedger += array{RelicID}
if (LostRelics.Length > 0):
DropoutMessage.Show()
Print("{LostRelics.Length} un-banked relics drift back to the ledger.")
# Step 2: remove the leaver's banked entry so no scoring
# pass ever iterates a ghost.
var RebuiltCounts : [player]int = map{}
for (Key -> Value : BankedCounts, Key <> Leaver):
if (set RebuiltCounts[Key] = Value) {}
set BankedCounts = RebuiltCounts
# Step 3: the round logic can now trust the roster.
Print("Divers remaining: {GetPlayspace().GetPlayers().Length}")
Step 3 — Read the cleanup like a Deeps archivist
| Piece | What it does | Why it matters |
|---|---|---|
GetPlayspace().PlayerRemovedEvent().Subscribe(OnDiverLeft) |
Registers the goodbye handler. Note the () before .Subscribe — playspace lifecycle events are functions returning a listenable, unlike device events such as TriggeredEvent. |
Forgetting the () is the classic first compile error of this lesson. |
TakeCarried(Diver : player)<transacts>:?[]int |
The West Coves import. Returns option{Relics} when the diver has an entry, false when they don't — and rebuilds the map without them. |
One option-safe door into the map. Banking and dropout share it, so there is exactly one place map removal can go wrong (and it doesn't). |
if (LostRelics := TakeCarried(Leaver)?) |
Failable binding + the ? unwrap. If the leaver had no entry, the whole block skips quietly. |
No crash, no special-casing. The option carries both the data and the "did they exist" answer. |
for (Key -> Value : CarriedRelics, Key <> Diver) |
The map-removal idiom: Verse maps have no delete, so you rebuild the map excluding one key. | Every [player]-keyed system in the capstone uses this exact loop. |
set RelicLedger += array{RelicID} |
Returns each un-banked relic to the ledger array. | The round economy stays closed: relics are never destroyed by a disconnect. |
Step 4 — Test the goodbye
Launch a session with two players, have one grab a relic (call PickUpRelic from your round logic or a temporary trigger), then have them leave. Watch the log: the relic count drifts back into the ledger, BankedCounts drops to one entry, and the remaining-diver print confirms the roster is clean.
Common patterns
Pattern 1 — Mixed rounds: participants, not just players
using { /Fortnite.com/Playspaces }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
using { /Fortnite.com/Devices }
# When your round mixes human divers and AI guardians, listen to
# ParticipantRemovedEvent (payload: agent) and narrow to player.
participant_watch_device := class(creative_device):
OnBegin<override>()<suspends>:void =
GetPlayspace().ParticipantRemovedEvent().Subscribe(OnParticipantLeft)
OnParticipantLeft(Who : agent):void =
# Bots are agents but not players -- narrow before touching
# any [player]-keyed state.
if (P := player[Who]):
Print("A human diver left -- run the full cleanup.")
else:
Print("An AI guardian despawned -- no ledger work needed.")
Pattern 2 — Never wait on a ghost
using { /Fortnite.com/Devices }
using { /Fortnite.com/Playspaces }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# After cleanup, never leave a round waiting on a ghost: if only
# one diver remains, end it early instead of soft-locking.
last_diver_device := class(creative_device):
@editable
WinnerScreen : end_game_device = end_game_device{}
OnBegin<override>()<suspends>:void =
GetPlayspace().PlayerRemovedEvent().Subscribe(OnDiverLeft)
OnDiverLeft(Leaver : player):void =
Remaining := GetPlayspace().GetPlayers()
if (Remaining.Length = 1, LastDiver := Remaining[0]):
WinnerScreen.Activate(LastDiver)
Where this goes next
This device is the disconnect spine of Athenaeum Depths (the Deeps capstone). The Sunken Dive Round (lesson 8) imports it directly: its race{}-based air-supply phase calls BankRelics when a diver surfaces, and relies on OnDiverLeft so a mid-dive disconnect returns relics to the same RelicLedger the whole round is scored from. The two-phase heist can only trust its ledger because every exit — banking, elimination, or a yanked network cable — flows through the one option-safe TakeCarried door you built here.
And the lineage runs backwards too: TakeCarried is the West Coves return-an-option lesson doing real work, and the whole device is the Deeps extension of the South Shores dropout intro. Same goodbye, deeper water.