174 lines
7.9 KiB
Markdown
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.
|