Yet Another SimDOCS
GitHub Open simulator
On this page

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:

MapDirection
connectionsparticipantId → the socket currently serving it
participantsclientId → 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.

PhaseWorldRecordedJoinable
setupnone — nobody has entered
workshopthe empty arena (EMPTY_RAID_ID)noyes, immediately
raidthe selected authored raidyesno, 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) — from setup, or from raid once its pull is idle.
  • start (host) — from workshop only, 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:

  • slots and observers are durable reservations. They survive a raid change, a stop, and a refresh, and are released only by an explicit releaseSlot/releaseObserver or by a genuine disconnect with no replacement.
  • pullRoster and pullObservers are the roster frozen from those reservations when a pull begins, and re-frozen on stop and restart.

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.
  • phase must be current before loadPull() runs, because resetPull() 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 sends simEnded.
  • 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.