433 lines
22 KiB
Markdown
433 lines
22 KiB
Markdown
# Multiplayer — design and build plan
|
||
|
||
How Station Master becomes a multiplayer game with an authoritative server, **as a layer added on top
|
||
of what exists**. Solitaire keeps working exactly as it does today: opened from a static host, played
|
||
entirely in the browser, no server involved at any point.
|
||
|
||
This is a plan, not an implementation. Written against the engine as built (v0.3.1). It reconciles
|
||
[`overview.md`](overview.md), [`protocol.md`](protocol.md),
|
||
[`lobby-and-sessions.md`](lobby-and-sessions.md) and [`deployment.md`](deployment.md) — all written
|
||
*before* the engine existed — with what was actually built, and with the decisions reviewed and
|
||
settled in §11.
|
||
|
||
---
|
||
|
||
## 1. The constraint that shapes everything
|
||
|
||
> Solitaire still plays the same. No server needed.
|
||
|
||
That decides the architecture:
|
||
|
||
- The engine stays **pure and browser-runnable**. No server-only dependency enters `src/engine/`.
|
||
- The client runs in **two modes** without forking: authoritative-local (solitaire, today's
|
||
behaviour) and view-only-remote (multiplayer).
|
||
- The static deploy keeps working. A server is an *additional* way to run the game.
|
||
|
||
The existing architecture anticipated nearly all of this and the engine was built to it. Most of what
|
||
follows is assembly.
|
||
|
||
---
|
||
|
||
## 2. What holds, and what moved
|
||
|
||
`overview.md`'s core claims survived contact with the implementation: server authority, a pure
|
||
deterministic engine, two entry points (`applyIntent` / `pump`), `state = fold(events)`, events that
|
||
render standalone, per-player redacted views.
|
||
|
||
Three things moved:
|
||
|
||
**The save format is already the wire format.** A game is `{ seed, history: Intent[] }` and `fromSave`
|
||
reconstructs it exactly. That is what makes persistence nearly free (§7).
|
||
|
||
**`protocol.md`'s message vocabulary was out of date; fixed in Phase 0.** It had been written from
|
||
the rules before the engine existed, and the real `Intent` union diverged — different names
|
||
(`switch.move`, not `Switch.Move`), different shapes (`dropCars` grew `fromNose`; `card.play` grew
|
||
`variant` and `node`), and intents that did not exist then (`switch.sortConsist`,
|
||
`newTrain.secondSection`, `maneuver.*`, `redFlag.*`), plus 40-odd rejection codes against a listed
|
||
20. **The real types are the protocol**, so `protocol.md` now points at `intents.ts` and `events.ts`
|
||
and keeps only what the types cannot say.
|
||
|
||
**The client already renders from a projection.** `snapshot()` produces a `Frame`, `actionMenu()` a
|
||
`Menu`, and the renderers consume only those. This is why the multiplayer client is cheap.
|
||
|
||
---
|
||
|
||
## 3. Measurements this plan rests on
|
||
|
||
Taken from 8 four-player bot games and 12 solitaire games, so the sizing is not guesswork.
|
||
|
||
| | value |
|
||
| --- | --- |
|
||
| Intents per game (4 players) | ~355, over ~16 Stages |
|
||
| Intents per player per game | ~89 |
|
||
| Intents per Stage | 22 — about 5.5 yours, 16.7 watched |
|
||
| Phase split | Local Ops 78%, Load/Unload 19%, New Train 3% |
|
||
| A turn: switch / draw / freight agent | 5.8 / 4.3 / 2.0 intents |
|
||
| Frame | 9.3 KB mean; **2.9 KB** with the board delta'd |
|
||
| Menu | 2.2 KB mean, 9.8 KB max |
|
||
| Events per intent | 3.3, ~160 B |
|
||
| Push rate | ~0.4 per second across a whole game |
|
||
|
||
Two consequences worth stating plainly:
|
||
|
||
**Latency is not the problem.** Watching does not block — pushes arrive asynchronously. The only
|
||
latency a player feels is on their own ~89 actions: **1.7 seconds across an entire game** on a LAN.
|
||
Nothing here is a performance decision.
|
||
|
||
**Idle time is the problem.** With four players you watch ~17 of 22 intents per Stage. That is a
|
||
turn-order property of the rules, not of the implementation — see §11 D19 for what is being kept
|
||
open about it.
|
||
|
||
> **Open, and not a plan decision:** a 4-player competitive game ends after **~16 Stages of a
|
||
> possible 60**, well before the 5-day limit. Most likely the collision or revenue floor (§3.4)
|
||
> firing early. Worth understanding before building a lobby around game length — it may be a balance
|
||
> bug rather than intent.
|
||
|
||
---
|
||
|
||
## 4. The structural idea: a Session boundary
|
||
|
||
Today `main.ts` calls `submit(game, intent)`, which applies in-process and re-renders. Introduce one
|
||
interface between the UI and the game:
|
||
|
||
```
|
||
Session
|
||
view() -> Frame what to draw
|
||
menu() -> Menu what may be done
|
||
submit(intent) -> Promise<ok> propose an action
|
||
subscribe(cb) -> unsubscribe "something changed, re-render"
|
||
capabilities -> { undo, saveLocal, newGame }
|
||
```
|
||
|
||
- **`LocalSession`** wraps today's `Game`: applies through the engine, pumps `advance`, keeps
|
||
`history`, supports undo and `localStorage`. **This is solitaire, unchanged.**
|
||
- **`RemoteSession`** holds no authoritative state. Posts intents, receives `Frame` and `Menu`,
|
||
re-renders on push. Undo and local save are absent from `capabilities`, so the UI hides those
|
||
controls rather than failing when pressed.
|
||
|
||
The client cannot keep authoritative state in remote mode even if it wanted to: it has neither the
|
||
deck order nor the other players' hands.
|
||
|
||
---
|
||
|
||
## 5. What the client must stop doing
|
||
|
||
`main.ts` reaches into `game.state` in **11 places**. Five new `Frame` fields remove all of them:
|
||
|
||
| Reads today | Fix |
|
||
| --- | --- |
|
||
| `clock.day`, `clock.stage`, `clock.phase` | already on `Frame` |
|
||
| `turn.option` | **add** — and under §6 it becomes *your* turn, not *the* turn |
|
||
| `status`, `outcome` | **add** |
|
||
| `players` (names and revenues of every seat) | **add** — the scoreboard is public |
|
||
| `decks.hands.get(actor).length` | **add** as `handCount`, plus per-seat counts |
|
||
|
||
Everything else it draws already comes from `Frame`/`Menu`.
|
||
|
||
**Solitaire-only, gated by `capabilities`:** undo (other players have seen the result), `localStorage`
|
||
save/restore, new-game-by-seed-in-URL.
|
||
|
||
---
|
||
|
||
## 6. Per-player turn state
|
||
|
||
`s.turn` is a **single** `TurnState` — one `option`, one `movesRemaining`, one `freightWorked` — read
|
||
in 50 places, with a phase driver that walks players one at a time calling `freshTurn()` as it goes.
|
||
|
||
**Phase 0 makes it per-player: `turns: Map<PlayerIndex, TurnState>`, with a turn created for every
|
||
player at phase entry.** This is deliberately **behaviour-neutral** — the cursor still advances one
|
||
player at a time, so play is identical and every existing test still describes the same game.
|
||
|
||
It is done now because the engine is least encumbered now, and because the alternative later means
|
||
the same work *plus* reworking a client built around "wait your turn". There is no data migration
|
||
either way: we persist intents, not state.
|
||
|
||
**What it buys later.** There is exactly one `NOT_YOUR_TURN` gate in the engine (`apply.ts:463`,
|
||
delegating to `isActor`). Once turn state is per-player, allowing genuinely local work to happen
|
||
off-cursor is a change to that one function:
|
||
|
||
```
|
||
isActor(s, player, intent)
|
||
local intent -> that player's own turn isn't done
|
||
shared intent -> player is at the shared cursor
|
||
```
|
||
|
||
An intent is **local** iff it touches only the acting player's Office Area **and** does not advance
|
||
the shared RNG (three sites: the reshuffle, the D12 that schedules a train, and setup).
|
||
|
||
| Local | Shared |
|
||
| --- | --- |
|
||
| `switch.move`, `dropCars`, `sortConsist` | `draw.fromHomeOffice`, `draw.fromDepartment` |
|
||
| `card.play` — track, facility, office, modifier, enhancement | `card.discard` (buries a shared pile) |
|
||
| `localOps.choose`, `switch.end`, `draw.end` | `card.play` — **train cards**, which roll the D12 |
|
||
| | `freightAgent.*`, and the yards generally |
|
||
|
||
That flip is **not** part of this plan. See D19.
|
||
|
||
---
|
||
|
||
## 7. Redaction — the trust boundary
|
||
|
||
`protocol.md` §4's table governs, and `Frame` was designed for it: `Frame.hand` is the *viewer's own*,
|
||
`Frame.deck` is a count, and the seed is not on it.
|
||
|
||
**The redaction surface is four fields, not sixty event types.** An earlier draft of this document
|
||
claimed the latter and it was wrong — it biased the design. The genuinely secret things are:
|
||
|
||
| Secret | Why |
|
||
| --- | --- |
|
||
| `seed`, `rngState` | leak every future shuffle and roll |
|
||
| `decks.homeOffice` — contents *and* order | §12.1; the count is public, the pile's height is visible |
|
||
| `decks.hands` — other players' | owner only; counts public |
|
||
| `cards` (the id→kind map) | the dictionary that turns any leaked id into a known card |
|
||
|
||
Everything else is public by the rules: the board, the Division, trays and consists, the timetable,
|
||
the yards, the salvage pile, the face-up Departments, revenues, held Red Flags, the clock and whose
|
||
turn it is. `trainScheduled` is **public** — `trainNumber`, `roll` and `slot` all belong on screen;
|
||
only its `rngState` field must be stripped.
|
||
|
||
So redaction reduces to: **call `snapshot(s, seat)` and never send `GameState`.** Because `Frame`
|
||
resolves card names and descriptions server-side, the client never needs the `cards` dictionary at
|
||
all.
|
||
|
||
**This must be enforced, not assumed.** Phase 2's redaction test is the single most important test in
|
||
the plan: serialize a seat's payload and assert it contains no other seat's card ids, no deck array,
|
||
and no rng state.
|
||
|
||
---
|
||
|
||
## 8. Server shape
|
||
|
||
One process, one port, same origin. Three layers:
|
||
|
||
```
|
||
transport HTTP: lobby, intents, static assets. SSE: the per-seat stream.
|
||
|
|
||
game session one per active game: owns state, serializes intents, pumps advance,
|
||
| computes per-seat Frame+Menu, appends to the log
|
||
|
|
||
rules engine unchanged, imported as-is from src/engine/
|
||
```
|
||
|
||
The session host is thin, because the engine does the hard part:
|
||
|
||
```
|
||
on intent(seat, intent, seq):
|
||
if seq already applied: ignore // idempotent resend
|
||
result = applyIntent(state, seat, intent)
|
||
if not result.ok: reply Rejected(result.code)
|
||
append intent to the log
|
||
pump(state) // drain the automatic phases
|
||
for each connected seat:
|
||
push { frameΔ: delta(snapshot(state, seat)), menu: seat may act ? menuFor(seat) : null, lines }
|
||
```
|
||
|
||
`pump` after every intent is what today's `drain()` does, and it is why the Mainline Phase needs no
|
||
special handling: `pump` stops, and the next push simply carries a `Menu` containing
|
||
`mainline.clearance` for whoever must rule.
|
||
|
||
**Bots run server-side** using the existing `developerBot`, for seats chosen at lobby time only.
|
||
|
||
---
|
||
|
||
## 9. Transport, and multiple addresses
|
||
|
||
**SSE for push, HTTP POST for intents.**
|
||
|
||
```
|
||
POST /api/lobby/create, /api/lobby/join lobby
|
||
POST /api/intent { gameId, seq, intent }
|
||
GET /api/stream EventSource — per-seat frames, with Last-Event-ID resume
|
||
GET / the client
|
||
```
|
||
|
||
Chosen over WebSocket because this game is **idle most of the time** — turn-based with human
|
||
think-time means a connection sits silent for minutes, exactly when proxies reap sockets. SSE's
|
||
reconnection and `Last-Event-ID` resume are handled by the browser, and it needs no `Upgrade` support
|
||
from anything in the path. WebSocket is supported on StartOS (`recipe-multi-interface.md`;
|
||
`cln-startos` ships one) and remains a contained swap behind the `Session` interface if
|
||
bidirectionality is ever wanted. Two channels means intents carry a `seq`, which the design already
|
||
required for idempotent resend.
|
||
|
||
**Players will reach one server at different addresses** — a LAN `IP:port` and a clearnet subdomain,
|
||
in the same game. That works because the server serves the client from the same origin, so each
|
||
browser is same-origin with itself and **no CORS is involved at any point**. One rule makes it hold:
|
||
|
||
> The client derives every endpoint from `location`. Never from configuration, never baked in.
|
||
|
||
Two consequences:
|
||
|
||
- **`localStorage` is per-origin**, so a session token exists only at the address it was created at.
|
||
**A player rejoins at the address they joined from.** This is a stated constraint, not a bug to fix.
|
||
- The two paths have different reliability characteristics — only the clearnet player traverses TLS
|
||
termination and ingress. SSE's automatic recovery is what makes that difference not matter.
|
||
|
||
---
|
||
|
||
## 10. Identity, access and persistence
|
||
|
||
**Access: a server-wide join secret**, set by environment variable and passed out of band by whoever
|
||
runs the server. Anyone holding it may create a game, and may join any created game that has not
|
||
started. No accounts, no user database. *(Revisit — see `TODO.md`.)*
|
||
|
||
**Identity: a session token scoped to one game**, exactly as `lobby-and-sessions.md` already
|
||
describes. Joining issues it; presenting it *is* the rejoin, because it already names the game and
|
||
the seat:
|
||
|
||
```
|
||
Session token, gameId, seat, displayName
|
||
```
|
||
|
||
The join secret is separate and server-wide — typed once and kept per-origin for convenience. It
|
||
gates entry; the token identifies a seat.
|
||
|
||
**One game at a time per person is the expected usage, and is deliberately not enforced.** Enforcing
|
||
it would mean a server-scoped token carrying an `activeGame`, and then answering what clears it — a
|
||
finished game, a player who leaves, a game that never ends — which is real state to get wrong for no
|
||
benefit. Nothing in the design needs a person to be in only one game, so nothing checks. A browser
|
||
that ends up holding two tokens simply has two games.
|
||
|
||
**Persistence: `{ engineVersion, seed, config, history: Intent[] }`**, append-only per game, plus a
|
||
small index. Rebuilding is `fromSave`, already implemented and exercised by every published replay.
|
||
No state snapshot — see D6.
|
||
|
||
**Games do not survive a rules change, by design.** A saved intent legal under old rules is rejected
|
||
under new ones; that happened four times in one release. So every game is stamped with its engine
|
||
version, and on load a mismatch is **refused with an explicit message** rather than silently
|
||
truncated. The upgrade path is: stop new games on the old version, let running ones drain.
|
||
|
||
**Retention: finished games keep everything**, because replay reveals everything (D20) and the log is
|
||
all it needs.
|
||
|
||
---
|
||
|
||
## 11. Decisions — reviewed and settled
|
||
|
||
| # | Decision | Rationale |
|
||
| --- | --- | --- |
|
||
| **D1** | **SSE + HTTP POST** | The game is idle for minutes at a time and players arrive by different paths; browser-native reconnect and resume, no `Upgrade` dependency. Swap is contained behind `Session`. |
|
||
| **D2/D3** | **Push `Frame` + `Menu`, delta'd. No raw event stream.** | Latency is not the deciding factor (1.7 s per game on a LAN), so decide on correctness: one reducer, one redaction chokepoint, no card dictionary on the client. 2.9 KB per push with the board omitted when unchanged — reusing `replay.ts`'s packing, already tested for losslessness. |
|
||
| **D4** | **One client bundle**, mode switch | The engine ships either way; in remote mode it is simply not used for authority. |
|
||
| **D5** | **Persist intents**, not events | Smaller, already the save format, already proven by `fromSave`. |
|
||
| **D6** | **No state snapshot** | A second format to keep correct, and D7 removes the need. |
|
||
| **D7** | **Stamp the engine version; refuse to resume a mismatch** | Rules changes invalidate stored intents. Drain before upgrading. |
|
||
| **D8** | **Bots fill empty seats at lobby time only** | Makes short-handed games and one-person testing possible. Never automatic on disconnect — see the two `TODO.md` items. |
|
||
| **D9** | **Separate seat from identity, now** | Employee Rotation needs a player's score to follow them while their seat changes. The single hardest thing here to retrofit, and the codebase is smallest today. |
|
||
| **D10** | **No hotseat** in v1 | Nearly free once seats exist, but a different UX. Additive later. |
|
||
| **D11** | **Server accepts 1-seat games**, but solitaire stays the static build | Falls out of seats generalising, and is genuinely useful for testing and validation. Not a featured path — playing alone gains nothing from a round trip. |
|
||
| **D12** | **No spectators** in v1 | A view with no private section and no intent rights. Additive later. |
|
||
| **D13** | **No accounts** | Display name plus a per-game session token, as `lobby-and-sessions.md` describes. One game at a time per person is the expected usage but is **not enforced** — doing so would add cross-game state to get wrong for no benefit. |
|
||
| **D14** | **Server-wide join secret**, passed out of band | The server may be clearnet-reachable. Cheapest thing that stops it being someone else's game server. Revisit. |
|
||
| **D15** | **Build to `deployment.md`'s five portability rules; package as an `.s9pk`** | This is a StartOS packaging workspace and the toolchain is on disk. |
|
||
| **D16** | **The server serves the client** | Required for same-origin, which is what makes multiple access addresses work without CORS. |
|
||
| **D17** | **Undo is solitaire-only** | Other players have seen the result. |
|
||
| **D18** | **Player cap 6** | Per `lobby-and-sessions.md` §2. |
|
||
| **D19** | **Per-player turn state now; parallel turns deferred** | The model change is behaviour-neutral and cheap today. Whether local work should run off-cursor depends on how often humans choose to switch — 13% for the bot, and the benefit ranges from ~8 minutes to ~27 off an hour-long game between 13% and 50%. Measure with real players, then flip one function. |
|
||
| **D20** | **Replay reveals everything once the game ends** | Most useful for learning and for arguing about it afterwards; costs nothing extra to retain. |
|
||
|
||
**Still open, and deliberately so:** whether local turns should run in parallel (D19, needs human
|
||
switching data); whether the join secret is enough (D14); and the ~16-Stage game length in §3, which
|
||
is a balance question rather than an architecture one.
|
||
|
||
---
|
||
|
||
## 12. Build plan
|
||
|
||
Sizes use `components.md`'s scale. Each phase ends somewhere demonstrable.
|
||
|
||
### Phase 0 — Engine and client preparation · M — **done, v0.4.0**
|
||
|
||
Safe to do now, and all of it improves the code whether or not multiplayer ships. Larger than a
|
||
typical "phase 0" because two structural changes are cheapest here.
|
||
|
||
1. **Separate seat from identity** (D9). `Player` carries id, name and revenue; the seat carries the
|
||
Office. Touches setup, `areaOf`, scoring, seating, the Division build and most tests.
|
||
2. **Per-player turn state** (D19). `turns: Map<PlayerIndex, TurnState>`, one per player at phase
|
||
entry, `turnOf(s, player)` at 50 sites. **Behaviour-neutral** — the cursor still walks one player
|
||
at a time, and every existing test must still pass unchanged.
|
||
3. Add `option`, `status`, `outcome`, `players[]`, `handCount` to `Frame`.
|
||
4. Remove all 11 `game.state` reads from `main.ts`; add a test that it never regains one.
|
||
5. Rewrite `protocol.md` to reference `intents.ts` and `events.ts` rather than restate them.
|
||
|
||
**Done when:** solitaire plays identically, the client renders from `Frame` + `Menu` alone, and the
|
||
engine has per-player turn state that nothing yet exploits. *All five shipped in v0.4.0. Item 1 found
|
||
a real bug on the way — `awardDeparture` was indexing `s.players` with a seat.*
|
||
|
||
### Phase 1 — The Session boundary · S — **done, v0.4.0**
|
||
|
||
6. Define `Session`; implement `LocalSession` around today's `Game`.
|
||
7. Move `main.ts` onto it, with `capabilities` gating undo, save and new-game.
|
||
|
||
**Done when:** solitaire plays identically through the new interface. Maximum "nothing appears to have
|
||
happened"; the regression suite is the proof. *Shipped in v0.4.0. The proof is `test/session.test.ts`,
|
||
which plays seed 77 to a finish through both routes and asserts the boards and histories match, plus a
|
||
source check that `main.ts` never regains a `GameState` read or a value import from `game.ts`.*
|
||
|
||
### Phase 2 — Server core, one game, no lobby · M
|
||
|
||
8. Process bootstrap: env-configured bind address, port, data directory, join secret; static serving.
|
||
9. Game session host — the loop in §8.
|
||
10. Per-seat `Frame` + `Menu`, with the board delta reusing `replay.ts`'s packing.
|
||
11. **The redaction test** (§7). Fail loudly.
|
||
12. SSE stream and `POST /api/intent`, with `seq` and `Last-Event-ID`.
|
||
13. `RemoteSession` in the client.
|
||
|
||
**Done when:** two browsers — one on a LAN IP, one on a hostname — play a 2-player game to a finish.
|
||
|
||
### Phase 3 — Persistence and resumption · S
|
||
|
||
14. Append-only `{engineVersion, seed, config, history}` per game; index.
|
||
15. Load on start; rebuild via `fromSave`; refuse a version mismatch explicitly.
|
||
|
||
**Done when:** the server restarts mid-game and both clients carry on.
|
||
|
||
### Phase 4 — Lobby, sessions, reconnection · M
|
||
|
||
16. Join secret; per-game session token; display name.
|
||
17. Create/join by game code; bot seats; seating UI showing the west-to-east chain; config locked at
|
||
start.
|
||
18. Disconnect keeps the seat and announces it; reconnect resumes from `Last-Event-ID`.
|
||
19. Optional turn timer, off by default, **denying** clearance on expiry.
|
||
|
||
**Done when:** four people join from four browsers at two different addresses, one closes the tab and
|
||
rejoins where they left off.
|
||
|
||
### Phase 5 — Multiplayer content · M
|
||
|
||
20. The 10 Action and 12 Space-use cards; flip `opponentCardsInDeck` in `setup.ts`.
|
||
21. Facing Point Locks, Water Column and Overpass stop being dormant — already wired.
|
||
|
||
**Done when:** an opponent-directed card resolves against another player and the Enhancement that
|
||
answers it fires.
|
||
|
||
### Phase 6 — Package for StartOS · S
|
||
|
||
22. `.s9pk` per the workspace guide: interface, health check, backup of the data directory.
|
||
|
||
**Done when:** it installs on a StartOS box and players on two different addresses play a game.
|
||
|
||
**Shape of it:** Phases 0–1 are low-risk refactoring that stands on its own merits. Phases 2–4 are the
|
||
real system. Phase 5 is content and independent of the rest. Phase 6 is packaging.
|
||
|
||
---
|
||
|
||
## 13. Risks
|
||
|
||
**R1 — The switching UI is still the sleeper.** `components.md` called it that and it remains the
|
||
hardest interface in the game. Multiplayer does not make it harder, but four people now watch one
|
||
person use it.
|
||
|
||
**R2 — Redaction must be exactly right.** Everything else degrades gracefully; a redaction bug hands
|
||
someone else's hand to a player and cannot be walked back. Hence the explicit test, not a review.
|
||
|
||
**R3 — `Menu` peaks at 9.8 KB** against a 2.2 KB mean. If that bites, send placements only for a
|
||
selected card. Measure before optimising.
|
||
|
||
**R4 — Idle time, not latency, is what makes 4-player games drag.** You watch ~17 of 22 intents per
|
||
Stage. D19 keeps the door open; the instrumentation to decide it is a few lines and belongs in the
|
||
first real multiplayer games.
|
||
|
||
**R5 — The two provisional rules are still unplaytested.** The opening deal and departure Revenue are
|
||
both flagged for review in `TODO.md`. Phases 0–1 are safe regardless; **Phase 2 onward should wait
|
||
until those settle**, or the server gets built against rules that are still moving.
|