Files
station-master/docs/architecture/lobby-and-sessions.md
T
2026-07-31 07:19:57 -04:00

174 lines
7.9 KiB
Markdown

# 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.