Gitea#33. A session token is the only identity the game has, and it lives in exactly one place the player controls: their browser's localStorage, scoped to the origin they joined at. Lose it — a cleared profile, a private window, a different browser — and the seat is unreachable while the game runs on and the session sits intact on disk. Reported from the table: of two humans in one game the host reloaded straight back in, the joiner met an empty lobby. Diagnosed before it was fixed, and two server-side theories of mine were retracted on the evidence: no storage key changed in 0.8.0.11, nothing in the app deletes the secret or name, create and join both call persistSession, that game's sessions.json held both seats, and it resumed with 80 intents replayed. Both players used the same URL, so it was not a second origin either. The fix is a recovery link. An administrator mints a code for a named seat (admin-gated: deciding somebody lost a seat is a judgement no route can make); the player opens the link and the page trades the code for the token over a POST, then strips it from the address bar. The link never carries the token — lobby-and-sessions.md §1 says keep it out of URLs, and a recovery link is exactly what gets pasted into a chat. Single use, 30-minute expiry, held in memory because a restart dropping them is the right failure. server/claims.ts is a pure store, so single use, lazy expiry and one identical answer for unknown/spent/expired codes are tested rather than asserted. The admin game listing gained seatedPlayers — the seats a human holds a token for, read from the session map rather than guessed from player names — so the StartOS action can offer real players instead of bot chairs. No rule changed: `git diff v0.8.0.11..v0.8.0.12 -- src/engine/` is empty, so games in progress resume. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
344 lines
21 KiB
Markdown
344 lines
21 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. Access to the server is a **join secret** (§2); identity within a game is a **display
|
|
name** plus a **session token** the server issues on join and the client stores locally.
|
|
|
|
```
|
|
Session
|
|
token : opaque, unguessable
|
|
gameId
|
|
player : PlayerIndex
|
|
displayName
|
|
```
|
|
|
|
**It names the PLAYER, not the seat.** Those became different things in v0.4.0, and the difference is
|
|
exactly the case this field has to survive: under Employee Rotation (§4) a player changes chairs while
|
|
their Revenue and their identity stay with them. A token naming a seat would sit a returning player
|
|
down in someone else's Office. The seat is derived with `seatOf(state, player)` whenever it is needed,
|
|
which is one lookup and always current.
|
|
|
|
The token is what makes reconnection work: it proves "I am the player who was in this game," 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.
|
|
|
|
**Tokens are per-origin.** The server may be reached by more than one address — `stationmaster.<domain>`
|
|
and `<ip>:<port>` are both expected — and browser storage is scoped to the origin. A player who joined
|
|
at one address must come back to that address, or they are a stranger with no token. Say so in the
|
|
UI at join time rather than letting someone discover it when they cannot get back in.
|
|
|
|
**A lost token is recoverable, administratively** (Gitea#33). Everything above makes the token the
|
|
single point of failure: it lives in one browser's storage, and a cleared profile, a private window or
|
|
a different browser ends the seat with the game still running and the session still on disk. Seen at a
|
|
real table — the returning player met an empty lobby while their token sat intact in `sessions.json`,
|
|
and the only way back was an administrator reading the file off the volume and the player pasting it
|
|
into a devtools console.
|
|
|
|
So there is a supported path, in two halves that are gated differently on purpose:
|
|
|
|
```
|
|
POST /api/games/<id>/claim { player } → { code, expiresAt, … } admin secret
|
|
POST /api/claim { code } → { token, gameId, player, gameCode }
|
|
```
|
|
|
|
**The link carries the code, never the token** — which is the rule three paragraphs up, applied. A
|
|
recovery link is exactly the sort of thing that gets pasted into a chat, so what travels in the URL is
|
|
single-use and expires in thirty minutes (`server/claims.ts`), and the page trades it for the real
|
|
token over a POST as it loads (`?claim=` in `web/main.ts`, which strips it from the address bar either
|
|
way). A leaked code is worthless once spent; a leaked token is the seat for the rest of the game.
|
|
|
|
**Minting is administrative; spending is not.** Deciding that a particular person has lost a
|
|
particular seat is a judgement no route can make safely — anyone able to mint their own code could
|
|
take any chair at the table. Spending needs no secret because the player following the link is the one
|
|
person in the story who holds none; the code *is* the authorisation, and it is the same shape
|
|
(unguessable, one-time) as the token it hands back. The codes are held in memory: they are minted on
|
|
demand and spent within minutes, so a restart dropping them is the right failure, and persisting them
|
|
would put a credential-equivalent on the volume to solve a problem measured in seconds.
|
|
|
|
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 { secret, config, displayName, players, seed } → { gameId, gameCode, token, player }
|
|
Lobby.Preview { secret, gameCode } → { gameCode, hostName, config, players, seated }
|
|
Lobby.Join { secret, gameCode, displayName } → { gameId, gameCode, token, player }
|
|
Lobby.Leave { token, seat? } → { ok, closed? }
|
|
```
|
|
|
|
**These four messages are the one family with no types behind them**, because the engine has no
|
|
concept of a lobby — `intents.ts` starts at `localOps.choose`. Everywhere else, the code is the
|
|
protocol ([`protocol.md`](protocol.md)); here the prose is, until the server exists.
|
|
|
|
**The join secret gates the door.** Server-wide, set by environment variable, passed out of band by
|
|
whoever runs the server (`multiplayer.md` D14). Anyone holding it may create a game and may join any
|
|
created game that has not started. It is not per-game and it is not an account — it is the cheapest
|
|
thing that stops a clearnet-reachable box from being someone else's game server. Typed once and kept
|
|
per-origin, alongside the token.
|
|
|
|
A **game code** — short, human-speakable, e.g. `RAIL-4471` — is the discovery mechanism *inside* the
|
|
door. 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 seating screen also offers it as an invite **link**
|
|
(`…/play.html?lobby&code=RAIL-4471`), which is what a chat message wants — the link carries the code
|
|
and never the join secret, because the secret is the door key and travels out of band by design.
|
|
|
|
**A player reads the rules before taking a chair.** `Lobby.Preview` answers the same join secret with
|
|
the whole config, the host's name and who is seated, and takes no seat — added 2026-08-23, when the
|
|
alternative was sitting down blind and (until the same pass) having no way back out. **It never
|
|
carries the seed**: the seed decides every shuffle and every roll in the game, so it belongs to the
|
|
host alone.
|
|
|
|
**Two players may not share a display name.** The name labels the district on the Division map, it is
|
|
what the turn chart means by "waiting on Jesse", and `record()` puts it in front of every line that
|
|
player causes — so two of them make all three ambiguous, and the names lock at `Lobby.Start`. A
|
|
clashing join is refused (`NAME_TAKEN`, compared trimmed and case-insensitively) rather than silently
|
|
suffixed: a player should play under the name they chose, or be asked for another.
|
|
|
|
**Anybody may leave, and the host may clear a chair.** `Lobby.Leave` frees the seat, drops the token
|
|
from `joinOrder`, and passes host rights on exactly as a dropped connection does. Naming somebody
|
|
else's `seat` is host-only. When the last human leaves, the lobby is deleted outright — code, file and
|
|
index row — rather than left as a table of bots waiting for a host who no longer exists. Before this
|
|
existed a mis-join or a player who wandered off wedged the whole table, since Start needs every chair
|
|
filled and a bot may not be dropped onto an occupied seat.
|
|
|
|
**One game at a time per person is expected usage and is deliberately not enforced** (`multiplayer.md`
|
|
§10). Enforcing it needs cross-game state whose only job is deciding when to release someone, and
|
|
getting that wrong locks a player out of their own server.
|
|
|
|
The creating player is the **host**: they set the config (§3) and start the game. **If the host leaves
|
|
before `Lobby.Start`, host rights pass to the earliest-joined remaining player.** No lobby should ever
|
|
be stuck waiting on somebody who closed a tab, and the alternative — everyone leaves and someone makes
|
|
a new game — throws away the seating they had already agreed. Host rights end at `Lobby.Start`
|
|
regardless: the rules give no player special standing during play, and the Superintendent rotates
|
|
independently (§4).
|
|
|
|
**Player counts.** Solitaire is 1. Competitive and Co-op are **2 to 4**. Nothing in the engine enforces
|
|
a limit, so the lobby is where it is enforced — and the number is set to what is actually exercised
|
|
(`test/multiplayer.test.ts` plays 2, 3 and 4 to a finish) rather than to a guess. The cost of more is
|
|
real: the Division grows by one Office and one Mainline card per player (§4.3), and every added player
|
|
lengthens both the Superintendent's rotation and the phase-major waiting. Raise the cap when somebody
|
|
has played a bigger game and reported back, not before.
|
|
|
|
This is a lobby judgment, **not a rules limit** — §7 of the rules describes what happens at five or
|
|
more players (the New Train round ends mid-pass when the consist fills) and the engine implements it.
|
|
The cap is about what has been played, not about what the game can do.
|
|
|
|
---
|
|
|
|
## 3. Configuration, and when it locks
|
|
|
|
Set before start, immutable after — the whole of `GameConfig` (`state.ts`), which the host fills in
|
|
by choosing a **game type** and then editing whatever they like:
|
|
|
|
```
|
|
mode : solitaire | competitive | coop
|
|
days : how long the game runs
|
|
minCombinedRevenue, maxCollisionsPerDay, maxCollisionsTotal (0 = that condition is off)
|
|
pvpCardsAllowed : a property of the type, not a control — the cards are unbuilt (setup.ts)
|
|
optionalRules : { reducedVisibility, employeeRotation, emergencyToolbox }
|
|
houseRules : { startingHand, extraStart, revenue }
|
|
```
|
|
|
|
**The four game types** (`src/web/presets.ts`, Jesse's design 2026-08-23) are Co-op, Competitive,
|
|
Cutthroat and Solitaire, plus **Custom** — which is not a fifth type but the state of having edited
|
|
one, and is scored as whichever type it was edited away from. The type is *derived* by comparing a
|
|
config against the four, never stored, so a saved game carries no label that can disagree with its own
|
|
numbers. Seed, player count and Day count sit ABOVE the type on both screens as **parameters**: the
|
|
types are formulas in the table size and the length (the Revenue floor is 3 per player per Day in
|
|
Co-op, 2 in Competitive, nothing in Cutthroat), so changing one re-derives rather than making the game
|
|
Custom.
|
|
|
|
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.
|
|
|
|
`mode` and the seat count are checked together at start: solitaire is exactly 1, competitive and
|
|
co-op are 2 to 4 (§2). The engine will happily build a 7-player Division, so this is the only place
|
|
the limit exists.
|
|
|
|
---
|
|
|
|
## 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.
|
|
|
|
**Both are implemented.** The Division roll was drawn and discarded until v0.4.1 — `void
|
|
divisionRolls`, with seating fixed by array order — so §4.4 decided nothing and the seat/player split
|
|
was untested at runtime. It orders the chain now: `seating[seat] = player`, ascending by roll from
|
|
seat 0 (west, beside the Western Division Point) to the last seat (east, "highest is the Eastern
|
|
Division Point").
|
|
|
|
The rule names only those two ends, because at a table the players are already sitting in a chain and
|
|
the roll only says which way round it is. There is no physical table here, so the roll orders
|
|
everybody — it uses a number every player is already told to roll, and it makes the roll matter to
|
|
more than the winner. Ties break toward the lower player index sitting further east, the same
|
|
first-max-wins convention the Superintendent roll uses.
|
|
|
|
Both rolls are kept on the state as `openingRolls`, indexed by player, **so the lobby can show the
|
|
rolls rather than only their outcome** — it is the game's first moment of drama and there is no reason
|
|
to hide it. Show the chain forming.
|
|
|
|
**Solitaire is unchanged and must stay so:** one player is one seat, seating is trivially `[0]`, and
|
|
every published replay depends on that.
|
|
|
|
**What a seat is told as the game begins** (2026-08-23). The board used to simply appear, mid-Local
|
|
Operations, with a log already several bot turns deep and nothing marking where the game began. The
|
|
page now holds a deliberate beat on a handoff curtain, announces the game and its type, marks the top
|
|
of the log, and shows the code and the type in the header for the rest of the game — none of which is
|
|
new *data*, only the first time any of it was drawn.
|
|
|
|
**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. **The model supports this as of v0.4.0**:
|
|
offices and districts are keyed by seat, hands and Revenue and the Fedora by player, and `seating[]`
|
|
maps between them, so rotating is a one-line operation and every `areaOf(state, player)` caller
|
|
follows without changing. The *rule* is still unimplemented — nothing rotates `seating` yet — but the
|
|
structure it needs is in place and tested, which was the point of doing it early.
|
|
|
|
---
|
|
|
|
## 5. Disconnection and reconnection
|
|
|
|
Being turn-based makes this far easier than it would be in a real-time game. (It is no longer "one
|
|
actor at a time" — v0.4.0 gave every player their own `TurnState` precisely so that players can act in
|
|
parallel where the rules allow it, `multiplayer.md` D19. Nothing about disconnection changes: the game
|
|
still waits on whoever it is waiting on.)
|
|
|
|
**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 disconnect notice so everyone else can see why nothing is happening
|
|
— silence with no explanation is the worst version of this. There is no such event type today; it is
|
|
server-layer news about a *connection*, not about the game, so it belongs with the transport rather
|
|
than in `GameEvent`, which must stay replayable from a seed.
|
|
|
|
**On connect, a client is told about every other seat at once** (2026-08-23). A change notice alone
|
|
answered "who just left", never "who is here" — so a player arriving at a table where two people had
|
|
not opened the game yet was told nothing about them at all, which is precisely the question at the
|
|
moment a game starts. Each entry carries `seen`, separating **was here and dropped** from **has never
|
|
opened the game**: the first will probably be back, the second needs somebody to send them the link.
|
|
`seen` is remembered only for as long as the process runs, so after a restart every absent seat reads
|
|
as "not here yet" — the more cautious of the two. **Bot seats are never reported**: a bot holds no
|
|
connection and never will, and listing one puts "waiting on Bot 1" on every screen for the whole game
|
|
(found by playing a three-seat game, not by reading the code).
|
|
|
|
**On reconnect:** the client presents its token and the server replies with a **full current view**.
|
|
Not an event tail — a returning client needs the position, not the history of how it got there, and
|
|
the server can always produce the position because it holds the game
|
|
([`protocol.md`](protocol.md) §3). This is why catching up is 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.
|
|
|
|
**Nothing moves on an absent player's behalf.** No forcing timer, no bot stepping in. Jesse's call,
|
|
and the reasoning is the same one that keeps bots out of running games generally: the Superintendent's
|
|
clearance decision materially affects *other* players' scores, so anything that answers it
|
|
automatically changes the game rather than preserving it. Bots fill empty seats **at game start
|
|
only** (`multiplayer.md` D8). Two ways out of a permanently-halted game are recorded in `TODO.md` and
|
|
deliberately not designed here — a bot playing minimally-damaging defensive moves for someone who
|
|
stepped away, and a player *consenting* to resign their railroad to a bot. Both need care; neither is
|
|
a timer.
|
|
|
|
### The turn clock — a stopwatch, not a shot clock
|
|
|
|
**Record how long each player takes on each turn.** Not to enforce anything: to find out where the
|
|
game actually goes. "A 4-player game takes an evening" is currently a guess, and the useful version of
|
|
that answer is per-phase — whether the wait is Local Operations, or making up a train, or one player
|
|
thinking about a clearance while three others watch. That is what tells you which part is worth
|
|
speeding up, and it is the one measurement no amount of bot simulation can produce, because the bot
|
|
does not think.
|
|
|
|
Where it lives matters:
|
|
|
|
- **Outside the rules engine.** The engine has no clock and must not acquire one — it is pure and
|
|
deterministic given a seed, which is what makes every replay and every test work.
|
|
- **Outside the canonical record.** Timings are stored beside `{ engineVersion, seed, config, history }`,
|
|
never inside it. A replay must reproduce a game from decisions alone; if it depended on wall-clock
|
|
data it would no longer be reproducible from a seed.
|
|
- **In the session host**, which already sees each intent arrive and already knows whose turn it is.
|
|
Wall-clock at the start of a turn, wall-clock at the intent that ends it, per player per phase.
|
|
|
|
Show the current turn's elapsed time in the UI if it is unobtrusive — a table can self-regulate on
|
|
information alone, which is the polite version of a shot clock and costs nothing.
|
|
|
|
---
|
|
|
|
## 6. Persistence
|
|
|
|
Persist the **intents**, not a state snapshot and not the event log. This section said "event log"
|
|
until v0.4.0, on the strength of a `state = fold(events)` claim that was never true
|
|
([`protocol.md`](protocol.md) §3) — folding the log rebuilds card plays and switching, and not one
|
|
train movement. The intents genuinely do reconstruct a game, they are smaller still, and `fromSave`
|
|
already replays them, so a mid-game server restart is a replay rather than a recovery.
|
|
|
|
```
|
|
games : gameId → { engineVersion, config, seed, status, createdAt, gameCode }
|
|
history : gameId → ordered Intent list
|
|
sessions : token → { gameId, player, displayName }
|
|
```
|
|
|
|
`engineVersion` is stored beside the seed because a replay is only faithful under the rules it was
|
|
recorded with. A version mismatch on load must be refused explicitly rather than replayed and quietly
|
|
diverged (`multiplayer.md` Phase 3).
|
|
|
|
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 list per game. The constraint
|
|
worth honouring is that the **history is append-only**: rewriting it breaks the one property that
|
|
makes replay trustworthy. (Undo is not an exception — it replays the history *without* the last
|
|
intent, and a server does not offer it at all.)
|
|
|
|
**Snapshotting** is an optimisation, not a requirement. If a Campaign game's history grows long
|
|
enough that replay feels slow, periodically store a state snapshot with its intent count and replay
|
|
forward from there. Do not build this until it is needed. Measured today: a finished solitaire game
|
|
is ~350 intents and a few hundred bytes.
|
|
|
|
**Retention.** Finished games **keep their history, seed and config**, because post-game replay is a
|
|
wanted capability (see [`overview.md`](overview.md#post-game-replay--a-desired-future-capability))
|
|
and those three are the only things it needs — the replay viewer on the site already reads exactly
|
|
that file. 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.
|
|
|
|
**Turn timings** (§5) are stored per game beside the history, never inside it, and are retained on the
|
|
same terms — they are only interesting in aggregate, and only across enough games to see a pattern.
|
|
|
|
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: a spectator is a `Frame` with no hand and no ability
|
|
to send intents. `snapshot` already takes a viewing seat, so the only new thing is a viewer that
|
|
belongs to no seat — the same shape post-game replay already uses.
|
|
- **Horizontal scaling and cross-process coordination.** One process holds every active game
|
|
in memory.
|