Session lifecycle#
RelayRoom (src/server/relayRoom.ts) is the transport-agnostic owner of a session: its phase, its
slot state, and the authoritative input log. RelayServerRoom wraps it with the Colyseus lifecycle,
authentication, rate limiting and boundary validation, and injects the outbound send sink; the
tests inject their own.
This page documents the rules that file enforces. For how the relayed frames themselves work, see Deterministic lockstep; for the player-facing view of the same model, see Getting started.
Participant identity#
Ownership is keyed by participantId, never by the per-socket Colyseus client id.
A participantId is a stable per-tab UUID, generated by src/client/participant.ts, kept in
sessionStorage, and passed in the room's join options. The room keeps two maps:
| Map | Direction |
|---|---|
connections | participantId → the socket currently serving it |
participants | clientId → the participant it belongs to |
A reload opens a new socket for the same participant. join() rebinds the pair atomically, leaving
every reservation untouched. The old socket's leave arrives afterwards, and
disconnectClient() recognises it as superseded — the client id is absent from participants, or
connections no longer points at it — and releases nothing. Without that check the stale leave
would clear the slot the refreshed tab had just re-acquired, which is the refresh race this design
exists to close.
participantId is an opaque routing key, not authentication. Room options are client-supplied,
so RelayServerRoom.onAuth validates it against ParticipantIdSchema exactly as onCreate
validates sessionId and raidId. Anyone who knows a session URL can already join it; the
participant id only decides which seat a connection resumes.
The three phases#
phase is explicit state, never derived from the loaded raid id. The workshop phase is presented
to players as the waiting lobby — the code keeps the internal name, the UI and the player-facing
docs use the other.
| Phase | World | Recorded | Joinable |
|---|---|---|---|
setup | none — nobody has entered | — | — |
workshop | the empty arena (EMPTY_RAID_ID) | no | yes, immediately |
raid | the selected authored raid | yes | no, roster is frozen |
playback (idle/playing/paused/stopped/done) is tracked separately, so "which world is
loaded" and "is it running" can no longer contradict each other.
Transitions:
enterWorkshop(host) — fromsetup, or fromraidonce its pull is idle.start(host) — fromworkshoponly, and only with a real raid selected; starting the workshop as if it were a raid is rejected.stop/restart— stay in the current phase. A manual stop deliberately does not pass through the workshop and back; it resets the same raid to tick zero.leave(host Home) and automatic shutdown — end a raid back to the workshop.
selectedRaidId (what the HUD raid picker chose) is kept separate from raidId (what is loaded).
setRaid is host-only and rejected while a pull is playing. Once a world is loaded it also loads
the chosen raid — leaving workshop for raid if it has to — at tick zero in the stopped state.
Running it stays a separate, deliberate start/play, so nobody is dropped into a pull by a
misclick, and the pull's replay log opens on that press rather than on the selection. Only in
setup, or for the workshop's own raid id, does setRaid record the selection and stop there.
Reservations versus the pull roster#
Two structures, deliberately not one:
slotsandobserversare durable reservations. They survive a raid change, a stop, and a refresh, and are released only by an explicitreleaseSlot/releaseObserveror by a genuine disconnect with no replacement.pullRosterandpullObserversare the roster frozen from those reservations when a pull begins, and re-frozen onstopandrestart.
buildFrame(), setIntent(), the started broadcast and resync() read the roster only. That
single rule is what keeps a late arrival out of a running pull: a claim landing mid-pull writes a
reservation and nothing else, so it can never be handed the running pull's started state or input
history. "Queued" is therefore a derived condition — the reservation is yours, the roster entry is
not — rather than a parallel structure to keep in sync.
The workshop, and a raid sitting at tick zero, have no live pull to protect, so a claim there is admitted at once.
Every raid uses the canonical eight-slot roster (enforced by raidSchema), so slot ids never change
between raids and a raid switch leaves reservations standing.
Losing the host or the participants#
A raid cannot outlive the people in it. disconnectClient() ends the pull back to the workshop when
the room is in raid and either:
- the host participant is gone (
reason: "hostLost"), or - no participant in the frozen roster is still connected (
reason: "noParticipants").
Connected clients sitting on the setup screen, or merely queued for the next pull, do not keep a raid alive. Fully disconnected rooms still auto-dispose as before; this rule covers the rooms that stay alive only because someone is idling in setup.
A host reload is treated as a host loss: the two are indistinguishable at the transport, and a pull whose canonical client vanished cannot be continued. A non-host reload is gentler — the participant leaves the running pull but keeps its reservation, so it returns on the next one.
The shutdown broadcasts a transition message so clients can leave the raid view and say why,
rather than having the world swapped out from under them.
Per-pull bookkeeping#
loadPull()is the single path that produces a tick-zero world: it sets the raid, clears pending intents, freezes the roster, builds the world and resets the relay.phasemust be current beforeloadPull()runs, becauseresetPull()picks the relay's tick ceiling from it. The workshop has no mechanics and so never reaches a terminal world status — its ceiling sits exactly at the authored duration, with no grace slack. An authored raid gets the slack, as a defence against a host that never sendssimEnded.openPullLog()records authored raids only. The workshop is an interactive arena, not simulation content: recording it would clutter the replay browser and burn the first pull number.- Options (waymarks, bot pattern, RNG pins) rebuild the frozen world immediately when no pull is live, and are never applied mid-pull. They are set from the setup screen, which is why they always land between pulls.