# Lobby and Sessions Everything outside the rules engine: creating and joining games, seating, reconnection, and persistence. Target is **small self-hosted** scale — a handful of concurrent games among people who know each other. Written against [`../rules/rules-v0.2.md`](../rules/rules-v0.2.md). --- ## 1. Identity No accounts to begin with. A player is a **display name** plus a **session token** the server issues on join and the client stores locally. ``` Session token : opaque, unguessable gameId playerIndex displayName ``` The token is what makes reconnection work: it proves "I am the player who was sitting at seat 2," which is the only identity claim the game needs. Keep it out of URLs so it is not shoulder-surfed or pasted into a chat. Real accounts can be layered on later without touching the rules engine, which is exactly why [`overview.md`](overview.md) keeps that boundary sharp. --- ## 2. Creating and joining ``` Lobby.Create { config } → { gameId, gameCode, token } Lobby.Join { gameCode, displayName } → { token, playerIndex } ``` A **game code** — short, human-speakable, e.g. `RAIL-4471` — is the whole discovery mechanism. No matchmaking, no browsing, no public game list. Players are already talking to each other; the code just needs to survive being read aloud. The creating player is the **host**: they set the config (§3) and start the game. Host-only rights end at `Lobby.Start` — the rules give no player special standing during play, and the Superintendent role rotates independently (§5). **Player counts.** Solitaire is 1. Competitive and Co-op need at least 2. The upper bound is a practical judgment rather than a rules limit: the Division grows by one Office and one Mainline card per player (§4.3), and every added player lengthens the Superintendent's rotation and the phase-major waiting. **6 is a sensible cap** for the prototype. --- ## 3. Configuration, and when it locks Set before start, immutable after: ``` mode : solitaire | competitive | coop victory : firstToTarget | highestAfterDays length : short | standard | campaign optionalRules : { reducedVisibility, sisterTrains, employeeRotation, emergencyToolbox } ``` These must lock at `Lobby.Start`. Changing `length` mid-game would move the finish line; changing `mode` would switch which failure floors apply (§3.4, §3.5). Neither has a coherent meaning mid-game, so the server should refuse rather than try. --- ## 4. Seating **Seating order is not cosmetic.** The players form a physical west-to-east chain of Offices (§4.3), and seat order determines three separate things: 1. Which Office is adjacent to which — and therefore where each player's trains arrive from. 2. The Superintendent rotation, which passes one seat left every three Stages (§5). 3. Acting order within a phase, which starts at the Superintendent and proceeds left (Gap 1). So seating must be settled before the opening D12 rolls, and the lobby should show the chain visually — a player should see whose Office lies east and west of theirs before the game begins. **The opening rolls** (§4.4, §4.5) happen server-side at `Lobby.Start`, from the game seed: highest D12 takes the Eastern Division Point, and a second roll picks the opening Superintendent. Emit both as events so clients can show the rolls rather than just the outcome — it is the game's first moment of drama and there is no reason to hide it. **Employee Rotation** (Appendix B), if enabled, moves every player one seat left at the end of each Day, carrying their Revenue and the Fedora with them. Note what this means for the model: a player's *seat* changes while their *score* follows them, so `Player.index` and Office ownership must be separable. This is the one optional rule with real structural consequences — worth wiring in from the start rather than retrofitting. --- ## 5. Disconnection and reconnection Turn-based with one actor at a time makes this far easier than it would be in a real-time game. **On disconnect:** keep the seat. Do not remove the player, do not auto-play. The game simply waits if it was their turn. Broadcast a `PlayerDisconnected` event so everyone else can see why nothing is happening — silence with no explanation is the worst version of this. **On reconnect:** the client presents its token, and the server replies with a full current view plus the event tail it missed. Because state is `fold(events)` ([`protocol.md`](protocol.md)), catching up is a replay, not a special case. **On a player who does not come back:** the honest options at this scale are to wait, or to let the host end the game. An optional **turn timer** is worth having — a lobby setting, off by default, never part of the rules — that on expiry takes the safest legal action: | Phase | Timeout action | | --- | --- | | Local Operations | `Switch.End` / `Draw.End` — forfeit the remaining action | | New Train | `NewTrain.PassCar` if legal, else place any legal car | | Mainline clearance | **Deny** clearance — the safe answer; a held train costs a Stage, a wrecked one costs 5 Revenue and counts toward the collision floor | | Load/Unload | `LoadUnload.End` | Denying clearance on timeout is the right default and worth stating explicitly: the asymmetry between the two outcomes is large, and in Competitive mode a timed-out clearance that causes a wreck could end the game for everybody (§3.4). **Do not substitute an AI player.** The Superintendent's clearance decisions materially affect other players' scores; a bot making them on an absent player's behalf changes the game rather than preserving it. --- ## 6. Persistence Persist the **event log**, not a state snapshot. It is smaller, it is the thing that already exists, and it makes a mid-game server restart a replay rather than a recovery. ``` games : gameId → { config, seed, status, createdAt, gameCode } events : gameId → ordered event list sessions : token → { gameId, playerIndex, displayName } ``` Requirements are modest enough that the storage choice is genuinely open — SQLite on a single node covers this comfortably, and so would flat files with an append-only log per game. The constraint worth honouring is that the **event log is append-only**: rewriting history breaks the one property that makes replay trustworthy. **Snapshotting** is an optimisation, not a requirement. If a Campaign game's log grows long enough that replay feels slow, periodically store a state snapshot with its `eventSeq` and replay forward from there. Do not build this until it is needed. **Retention.** Finished games **keep their event log, seed and config**, because post-game replay is a wanted capability (see [`overview.md`](overview.md#post-game-replay--a-desired-future-capability)) and the log is the only thing it needs. Do not delete finished games by default. Retention is an operator setting rather than an application decision — a self-hosted instance among friends may as well keep everything, since a finished game's log is small. If a cap is wanted, expire by age or by total count and say so plainly in the UI, because a replay that silently stops existing is worse than one that was never offered. Session tokens are the exception: those can expire on a short window once their game has finished. --- ## 7. What this deliberately omits At small self-hosted scale these are not needed, and building them early costs more than it returns: - **Matchmaking or a public game browser.** The game code covers discovery. - **Ranking, ladders, persistent profiles.** §3's timed modes produce comparable scores, so a high-score table would be a natural first addition — but it is a feature, not infrastructure. - **Spectators.** Straightforward to add later, since a spectator is just a view with no `private` section and no ability to send intents — the same shape post-game replay needs. - **Horizontal scaling and cross-process coordination.** One process holds every active game in memory.