v0.4.0 — multiplayer Phases 0 and 1: seat and player split apart, turn state per player, the page behind a Session, and eight seat/player mix-ups fixed with tests that fail without them
This commit is contained in:
@@ -161,8 +161,8 @@ is what makes the whole system testable without a server.
|
||||
**11. Transport**
|
||||
|
||||
- **Where:** Server · **When:** per connection; pushes on every event
|
||||
- **Does:** HTTP for lobby operations, WebSocket for the ordered event stream, static asset serving.
|
||||
Bidirectional — most traffic is server → client.
|
||||
- **Does:** HTTP POST for lobby operations and intents, SSE for the ordered push stream, static asset
|
||||
serving ([`multiplayer.md`](multiplayer.md) D5). Most traffic is server → client.
|
||||
- **MVP: S** · **Final: M** · ~150 → ~400 LOC
|
||||
- **Size driver:** small at MVP because one client needs no fan-out. Grows with broadcast, backpressure
|
||||
and reconnect-with-replay.
|
||||
|
||||
@@ -15,7 +15,7 @@ The requirements are modest, which is what makes both paths viable:
|
||||
| Need | Why |
|
||||
| --- | --- |
|
||||
| One long-running process | Active games are held in memory ([`overview.md`](overview.md)) |
|
||||
| HTTP + WebSocket on one port | Lobby over HTTP, game stream over WebSocket ([`protocol.md`](protocol.md)) |
|
||||
| Plain HTTP on one port | Lobby and intents over POST, game stream over SSE — no WebSocket upgrade, which is what keeps this a *plain* HTTP need ([`multiplayer.md`](multiplayer.md) D5) |
|
||||
| A writable data directory | The append-only event log ([`lobby-and-sessions.md`](lobby-and-sessions.md)) |
|
||||
| Static asset serving | The browser client |
|
||||
| No outbound network access | The game talks to nobody |
|
||||
@@ -59,7 +59,7 @@ network access to that StartOS box. How they get it is the user's configuration
|
||||
something the application chooses or should claim.
|
||||
|
||||
**One thing the architecture must respect.** A packaged service should not assume it is reachable at
|
||||
a fixed, publicly-routable URL. Anything that bakes an origin into the client — absolute WebSocket
|
||||
a fixed, publicly-routable URL. Anything that bakes an origin into the client — absolute stream
|
||||
URLs, hard-coded hostnames in links, CORS allow-lists pinned to one domain — will break. Serve the
|
||||
client from the same origin as the API and use relative URLs throughout. This costs nothing on a
|
||||
plain web host and is required on StartOS, so it should simply be the rule.
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
# 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.
|
||||
@@ -115,13 +115,16 @@ trains move and wrecks happen while the Superintendent rules on clearances.
|
||||
That means the server must push. A request/response API alone would leave four players polling to
|
||||
watch a fifth switch cars.
|
||||
|
||||
**WebSocket, with the HTTP endpoints alongside it** for lobby operations (list games, create, join)
|
||||
where a request/response shape is the natural fit. Server-Sent Events would also serve, since the
|
||||
push is nearly one-directional and intents are rare enough to send over HTTP — worth keeping in mind
|
||||
if the eventual stack makes SSE materially simpler.
|
||||
**Server-Sent Events, with HTTP POST alongside** for lobby operations and for intents, where a
|
||||
request/response shape is the natural fit. This paragraph originally leaned the other way, toward
|
||||
WebSocket with SSE as the fallback; it was settled the other way in
|
||||
[`multiplayer.md`](multiplayer.md) D5, because the push is nearly one-directional, intents are rare,
|
||||
and SSE reconnects itself through anything in the path without a protocol upgrade to negotiate.
|
||||
|
||||
What matters more than the choice: **every state change reaches clients as an event on one ordered
|
||||
stream**. Clients apply events in order and never mutate state locally in a way that could drift.
|
||||
What matters more than the choice: **every state change reaches clients on one ordered stream**, and
|
||||
clients never mutate state locally in a way that could drift. What travels on that stream is a
|
||||
redacted `Frame`, not the raw events — see `multiplayer.md` D2/D3 for why one reducer and one
|
||||
redaction chokepoint beat shipping the history to every client.
|
||||
|
||||
---
|
||||
|
||||
@@ -159,7 +162,7 @@ Keep the **rules engine** free of any knowledge of networking, storage, or playe
|
||||
│
|
||||
game session owns one game's state, applies intents, emits events
|
||||
▲
|
||||
lobby / transport connections, reconnection, persistence, HTTP + WebSocket
|
||||
lobby / transport connections, reconnection, persistence, HTTP POST + SSE
|
||||
```
|
||||
|
||||
The rules engine being pure and deterministic is what makes the whole thing testable — you can drive
|
||||
|
||||
+83
-214
@@ -1,269 +1,138 @@
|
||||
# Protocol
|
||||
|
||||
The client↔server message vocabulary, and how per-player views are redacted. Written against
|
||||
[`../rules/rules-v0.2.md`](../rules/rules-v0.2.md) and [`game-state.md`](game-state.md).
|
||||
The client↔server message vocabulary, and how per-player views are redacted.
|
||||
|
||||
Three message families, matching the flows in [`overview.md`](overview.md):
|
||||
**The vocabulary is code, not prose.** This document used to spell out every message with an
|
||||
illustrative name and an illustrative payload. It was written before the engine existed, and the
|
||||
engine then went and defined all three families for real — so the document became a second,
|
||||
drifting, subtly-wrong copy of three TypeScript files. It is now a map of those files plus the rules
|
||||
that live nowhere else.
|
||||
|
||||
- **Intent** — client → server. A proposal. May be rejected.
|
||||
- **Event** — server → clients. A fact. Ordered, and the game's history.
|
||||
- **View** — server → one client. A redacted state snapshot.
|
||||
| Family | Direction | Authority |
|
||||
| --- | --- | --- |
|
||||
| **Intent** — a proposal, may be rejected | client → server | [`src/engine/intents.ts`](../../src/engine/intents.ts) — `Intent`, 28 variants |
|
||||
| **Event** — a fact, ordered, the game's history | server → clients | [`src/engine/events.ts`](../../src/engine/events.ts) — `GameEvent`, 46 variants |
|
||||
| **View** — a redacted projection | server → one client | [`src/sim/view.ts`](../../src/sim/view.ts) — `Frame` and `snapshot(state, …, seat)` |
|
||||
| **Rejection** — why an intent was refused | server → one client | `RejectionCode` in `intents.ts` |
|
||||
|
||||
Message names below are illustrative. What matters is the *set* of decisions a player can make, which
|
||||
is fixed by the rules.
|
||||
Read those files for the shapes. Each variant carries its own doc comment explaining the rule behind
|
||||
it and, where the field arrangement is load-bearing, why it is arranged that way. What follows is
|
||||
what the types cannot say.
|
||||
|
||||
The transport that carries these — SSE down, HTTP POST up — and the decision to push `Frame` + `Menu`
|
||||
rather than a raw event stream, are in [`multiplayer.md`](multiplayer.md) (D2/D3, D5). Written against
|
||||
[`../rules/rules-v0.2.md`](../rules/rules-v0.2.md) and [`game-state.md`](game-state.md); the flows are
|
||||
in [`overview.md`](overview.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Intents
|
||||
|
||||
Every intent carries `gameId`, `playerId`, and a `seq` the server uses to reject duplicates from a
|
||||
reconnecting client.
|
||||
**Intents are already the wire format.** A saved game is `{ seed, history: Intent[] }` and `fromSave`
|
||||
replays it, which means the client→server message type was fixed the day save/restore worked. There
|
||||
is no separate protocol layer to design and nothing to keep in sync.
|
||||
|
||||
### 1.1 Local Operations Phase
|
||||
Every intent is validated server-side even when the client only ever offers legal ones — `legalActions`
|
||||
and `check` are the same code, so a client-side menu is a convenience, never a guarantee.
|
||||
The framing an intent needs on the wire (`gameId`, who sent it, a `seq` for de-duplicating a
|
||||
reconnecting client's resend) is added by the transport and is not part of `Intent` itself: the engine
|
||||
is given the actor by its caller.
|
||||
|
||||
The phase opens with a three-way exclusive choice (§6). Choosing one forecloses the others for that
|
||||
Stage.
|
||||
### What is deliberately NOT an intent
|
||||
|
||||
```
|
||||
LocalOps.Choose { option: switch | draw | freightAgent }
|
||||
```
|
||||
These are the places where offering a choice would be a rules violation, and they are worth stating
|
||||
here because "the message is missing" looks like an oversight in a way that "the message exists" never
|
||||
does.
|
||||
|
||||
**If `switch`** — six Moves, divisible across Crew Trays (§6.1). Five Moves under Reduced Visibility
|
||||
during night Stages (Appendix B).
|
||||
- **Picking up cars.** Moving into standing cars couples them automatically and mandatorily (§A.4).
|
||||
The engine does it while resolving `switch.move`.
|
||||
- **Which cars come off.** `switch.dropCars` takes a *count*: cars come off in seated order (§A.3), so
|
||||
the only free choice is how many, and from which end (`fromNose`).
|
||||
- **Mainline movement.** The whole phase is automatic except the Superintendent's clearance decision
|
||||
(`mainline.clearance`, §8.1) — which arrives out of turn order and may concern another player's
|
||||
train. It is the one place a player acts during someone else's traffic.
|
||||
- **Scheduling.** `trainScheduled` comes from the 1D12 (§7); nobody chooses it.
|
||||
|
||||
```
|
||||
Switch.Move { trayId, to: GridCoord }
|
||||
Switch.DropCars { trayId, count } -- from the tray end; order is fixed (§A.3)
|
||||
Switch.SetDirection{ trayId, direction } -- costs a Move; a Move never reverses (§2.4)
|
||||
Switch.End { } -- forfeit remaining Moves
|
||||
```
|
||||
### Two loops the client must not flatten
|
||||
|
||||
Pick-up is **not** an intent. Moving into standing cars couples them automatically and mandatorily
|
||||
(§A.4) — the server does it as part of resolving `Switch.Move`. Offering a choice would be a rules
|
||||
violation.
|
||||
|
||||
`Switch.DropCars` takes a count, not a set of car ids: cars come off in seated order (§A.3), so the
|
||||
only free choice is how many.
|
||||
|
||||
**If `draw`** (§6.2):
|
||||
|
||||
```
|
||||
Draw.FromHomeOffice { }
|
||||
Draw.FromDepartment { slot: 0|1|2 }
|
||||
Card.Play { cardId, placement? } -- placement required for track/facility/office cards
|
||||
Card.Discard { cardId, toSlot: 0|1|2 }
|
||||
Draw.End { } -- server rejects while hand > 3
|
||||
```
|
||||
|
||||
`placement` is a `GridCoord` for track, Facility and Office cards (§11.2); absent for train cards,
|
||||
which go to the timetable or to an Extra's staging.
|
||||
|
||||
**If `freightAgent`** — exactly one of three operations (§6.3):
|
||||
|
||||
```
|
||||
FreightAgent.StockToOutbound { facilityId, stockType, loaded }
|
||||
FreightAgent.InboundToClass { facilityId, stockIndex }
|
||||
FreightAgent.UnjamToClass { facilityId, from: outbound|inbound|menAtWork, index }
|
||||
```
|
||||
|
||||
### 1.2 New Train Phase
|
||||
|
||||
Per train being made up, each player adds one car in Superintendent-then-left order (§7):
|
||||
|
||||
```
|
||||
NewTrain.PlaceCar { trainId, stockType, loaded }
|
||||
NewTrain.PassCar { trainId } -- only legal when no suitable car exists in the Division Yard
|
||||
```
|
||||
|
||||
**This cycles** (§7, Gap 9). The server keeps going round the table — one car per player per pass —
|
||||
until the consist is full or no suitable car remains in the Division Yard. It is not a single pass, so
|
||||
the client must not assume one prompt per player per train. At five or more players the round ends
|
||||
mid-pass when the consist fills; players not yet reached simply are not prompted.
|
||||
|
||||
`PassCar` must be validated, not trusted: §7 requires the player to "make every effort to find a
|
||||
suitable car." The server checks the Division Yard against the train's `consistSpec` and rejects a
|
||||
pass when a legal car is available. A pass by every player in a full pass is the loop's other
|
||||
termination condition.
|
||||
|
||||
For a played Extra (§7, Gap 4c) the owning player chooses its whole consist:
|
||||
|
||||
```
|
||||
Extra.Launch { trainCardId, at: westDP | eastDP, direction }
|
||||
Extra.LoadConsist { trainCardId, stock: [{stockType, loaded}] } -- ≤3 cars + caboose
|
||||
```
|
||||
|
||||
### 1.3 Mainline Phase
|
||||
|
||||
The phase is automatic **except** for the Superintendent's clearance decision (§8.1, fourth
|
||||
condition). The server pauses movement, sets `clock.pendingDecision`, and waits:
|
||||
|
||||
```
|
||||
Mainline.Clearance { trainId, allow: boolean }
|
||||
```
|
||||
|
||||
Only the current Superintendent may send this, and only for the train named in the pending decision.
|
||||
This intent arrives out of the normal turn order and may concern another player's train — the one
|
||||
place in the game where a player acts during someone else's traffic.
|
||||
|
||||
The Red Flag card (Emergency Toolbox, Appendix B) also interrupts here:
|
||||
|
||||
```
|
||||
RedFlag.Play { } -- averts an imminent collision; card is removed from the game
|
||||
```
|
||||
|
||||
### 1.4 Load/Unload Phase
|
||||
|
||||
Each Laborer and Porter is usable once per Stage (§9.1). Resolve one at a time.
|
||||
|
||||
```
|
||||
Porter.Board { facilityId } -- §9.2, +1 Revenue
|
||||
Porter.Detrain { facilityId } -- §9.2, +1 Revenue
|
||||
Laborer.AdvanceLoad { facilityId, loadRef } -- Green → MEN → AT → WORK → car (§9.3)
|
||||
Laborer.BeginUnload { facilityId, carIndex } -- first step of unloading
|
||||
LoadUnload.End { }
|
||||
```
|
||||
|
||||
A freight load needs four `Laborer.AdvanceLoad` intents to score one point; a Porter scores in one
|
||||
(§9.3). The client should show this clearly — it is the single most surprising thing about the game's
|
||||
economy for a new player.
|
||||
|
||||
### 1.5 Lobby
|
||||
|
||||
Covered in [`lobby-and-sessions.md`](lobby-and-sessions.md); listed here for completeness.
|
||||
|
||||
```
|
||||
Lobby.Create { config }
|
||||
Lobby.Join { gameCode, displayName }
|
||||
Lobby.Leave { }
|
||||
Lobby.SetConfig { config } -- host only, before start
|
||||
Lobby.Start { } -- host only
|
||||
```
|
||||
- **New Train cycles.** §7 goes round the table one car per player per pass until the consist is full
|
||||
or no suitable car remains. It is *not* one prompt per player per train. At five or more players a
|
||||
round can end mid-pass with players never prompted.
|
||||
- **`newTrain.passCar` is validated, not trusted.** §7 requires "every effort to find a suitable car",
|
||||
so the engine checks the Division Yard against the consist spec and refuses a pass when a legal car
|
||||
is there.
|
||||
|
||||
---
|
||||
|
||||
## 2. Rejections
|
||||
|
||||
An intent is rejected with a reason a client can render, not a stack trace:
|
||||
`RejectionCode` in `intents.ts` is the closed set. `applyIntent` returns
|
||||
`{ ok: false, code, message }` — a reason a client can show, never a stack trace. The `message` today
|
||||
is the code restated (`apply.ts`); a client that wants prose should key off `code`, which is stable,
|
||||
rather than parsing it.
|
||||
|
||||
```
|
||||
Rejected { seq, code, message, ... }
|
||||
|
||||
codes: NOT_YOUR_TURN | WRONG_PHASE | OPTION_ALREADY_CHOSEN | NO_MOVES_REMAINING
|
||||
ILLEGAL_MOVE | TRACK_OCCUPIED | NOT_OPERATIONAL_RAIL | WOULD_REVERSE
|
||||
CONSIST_FULL | CONSIST_ORDER | HAND_LIMIT | CARD_NOT_IN_HAND
|
||||
NO_PLACEMENT | NOT_CONNECTED | RESOURCE_SPENT | SUITABLE_CAR_EXISTS
|
||||
NOT_SUPERINTENDENT | NO_PENDING_DECISION
|
||||
```
|
||||
|
||||
The client may pre-validate to grey out illegal moves — good UX — but the server's answer is the only
|
||||
one that counts (`overview.md`). Rejections should be rare in a well-built client and are therefore
|
||||
worth logging server-side: a spike usually means client and server rules have drifted.
|
||||
A client may pre-validate to grey out illegal moves — that is what `legalActions` is for, and it is
|
||||
good UX — but the server's answer is the only one that counts. **Rejections should therefore be rare,
|
||||
which makes them worth logging server-side: a spike means client and server rules have drifted.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Events
|
||||
|
||||
Events are the authoritative history. State is `fold(events)`, which is what gives reconnection,
|
||||
persistence and replay for one design decision.
|
||||
Events are the authoritative history: `state = fold(events)`, which buys reconnection, persistence and
|
||||
post-game replay for one design decision.
|
||||
|
||||
**Phase and clock**
|
||||
**Events must render standalone** — the rule is stated at the top of `events.ts` and it is the one that
|
||||
gets broken by accident. Carry the `from`/`to`, not an id the renderer resolves against live state,
|
||||
or a replay viewer has to reconstruct the whole board to draw one frame.
|
||||
|
||||
```
|
||||
StageBegan { day, stage }
|
||||
PhaseBegan { phase }
|
||||
ActorChanged { playerIndex | null }
|
||||
SuperintendentChanged { playerIndex }
|
||||
DayEnded { day, revenues }
|
||||
```
|
||||
Two consequences worth knowing before adding an event:
|
||||
|
||||
**Local operations**
|
||||
|
||||
```
|
||||
LocalOpsOptionChosen { playerIndex, option }
|
||||
TrayMoved { trayId, from, to, movesRemaining }
|
||||
CarsCoupled { trayId, stock[] } -- automatic, from a Move
|
||||
CarsDropped { trayId, at, stock[] }
|
||||
CardDrawn { playerIndex, source, cardId? } -- cardId omitted for other players
|
||||
CardPlayed { playerIndex, cardId, placement? }
|
||||
OfficeUpgraded { playerIndex, from, to } -- property change; connections unaffected (§11.3)
|
||||
CardDiscarded { playerIndex, cardId, toSlot }
|
||||
DeckReshuffled { }
|
||||
```
|
||||
|
||||
**Trains**
|
||||
|
||||
```
|
||||
TrainScheduled { trainCardId, timetableSlot } -- from the 1D12 roll (§7)
|
||||
TrainMadeUp { trainCardId, trayId, at }
|
||||
CarPlacedOnTrain { playerIndex, trayId, stock }
|
||||
ClearanceRequested { trainId, followingInto, occupiedBy }
|
||||
ClearanceGiven { trainId, allow }
|
||||
TrainHighballed { trayId, from, to }
|
||||
TrainMoved { trayId, from, to }
|
||||
TrainCompleted { trayId, atDivisionPoint }
|
||||
```
|
||||
|
||||
**Consequences**
|
||||
|
||||
```
|
||||
CollisionOccurred { at, trains[], struckCars[], faultPlayer, penalty: 5 }
|
||||
RedFlagPlayed { playerIndex, avertedAt }
|
||||
RevenueChanged { playerIndex, delta, total, reason }
|
||||
CollisionCountChanged { collisionsToday }
|
||||
GameEnded { result, winner?, reason }
|
||||
```
|
||||
|
||||
`CollisionOccurred` carries `faultPlayer` explicitly rather than leaving clients to derive it. Fault
|
||||
depends on *where* the wreck happened — Superintendent for a Mainline card, the local player between
|
||||
his Limits (§10) — and getting that wrong misattributes a −5 and, in Competitive, contributes to a
|
||||
floor that ends the game.
|
||||
- **`collisionOccurred` carries `faultPlayer` explicitly** rather than leaving clients to derive it.
|
||||
Fault depends on *where* the wreck happened — Superintendent for a Mainline card, the local player
|
||||
between their Limits (§10) — and getting it wrong misattributes a −5 and, in Competitive, feeds a
|
||||
floor that ends the game.
|
||||
- **Events are not what goes over the wire.** The server folds them and pushes the resulting `Frame`
|
||||
(`multiplayer.md` D2/D3). They are the store and the replay format, not the protocol.
|
||||
|
||||
---
|
||||
|
||||
## 4. Views and redaction
|
||||
|
||||
Each client receives a projection with other players' private state removed.
|
||||
Each client receives `snapshot(state, …, seat)` — a `Frame`, which is already a projection built for
|
||||
rendering and already takes the viewing seat. Nothing else is sent.
|
||||
|
||||
| State | Visibility |
|
||||
| --- | --- |
|
||||
| Board: track grids, Offices, Limits, trains, standing cars | **Public** |
|
||||
| Facilities: boxes, `MEN AT WORK`, Laborer/Porter usage | **Public** |
|
||||
| Division Yard, Classification Yard contents | **Public** — physical piles on the table |
|
||||
| Salvage Yard | **Public** — face up, explicitly so players can audit discards (§2.6) |
|
||||
| Salvage Yard | **Public** — face up, so players can audit discards (§2.6) |
|
||||
| Department slots (the three face-up cards) | **Public** |
|
||||
| Home Office deck **contents and order** | **Secret** — never sent, to anyone |
|
||||
| Home Office deck **count** | Public — players can see the pile's height |
|
||||
| A player's hand | **Owner only**; others see the count |
|
||||
| Red Flag held | Public — it is a known starting card (Appendix B) |
|
||||
| Red Flag held | Public — a known starting card (Appendix B) |
|
||||
| Revenue totals | **Public** — the race is the game |
|
||||
| RNG seed | **Secret** — sending it leaks all future shuffles |
|
||||
| RNG seed and state | **Secret** — either leaks every future shuffle and roll |
|
||||
|
||||
```
|
||||
View {
|
||||
public : PublicState -- identical for every client
|
||||
private : { hand: CardId[], redFlag: boolean }
|
||||
you : PlayerIndex
|
||||
canAct : boolean
|
||||
legalIntents? : IntentSummary[] -- optional server-computed affordances
|
||||
}
|
||||
```
|
||||
**The redaction surface is four fields, not sixty event types** — `seed`, `rngState`,
|
||||
`deckReshuffled.order`, and `cardDrawn.cardId` when the draw was from the Home Office. The full
|
||||
argument, including why `trainScheduled` is public despite being a die roll, is in
|
||||
[`multiplayer.md` §7](multiplayer.md). It reduces to: **call `snapshot` and never send `GameState`.**
|
||||
|
||||
**Two redaction traps.** First, `CardDrawn` from the Home Office must omit `cardId` for every client
|
||||
except the drawer — the obvious version of this event leaks the draw to the table. Second, the deck
|
||||
*count* is public but the *order* is secret; a naive implementation that ships the deck array and
|
||||
tells the client not to look is not redaction.
|
||||
|
||||
`legalIntents` is optional but worth it: the server already computes legality to validate, so
|
||||
returning the affordance set costs little and removes any need for the client to reimplement rules
|
||||
like turnout directionality or the four-slot consist limit.
|
||||
**This is enforced, not assumed.** The redaction test is the single most important test in the
|
||||
multiplayer work: everything else degrades gracefully, a redaction bug hands a player the deck.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ordering and idempotency
|
||||
|
||||
- Events carry a monotonic `eventSeq` per game. Clients apply strictly in order and request a replay
|
||||
on a gap rather than guessing.
|
||||
- Events carry a monotonic sequence per game. Clients apply strictly in order and request a replay on
|
||||
a gap rather than guessing.
|
||||
- Intents carry a client `seq`. The server ignores a repeat of one it has already applied, so a
|
||||
reconnecting client can safely resend anything it is unsure about.
|
||||
- The server never applies two intents concurrently within a game. With one actor at a time this is
|
||||
free — a per-game queue is sufficient and there is no need for anything cleverer at this scale.
|
||||
- **The server never applies two intents concurrently within a game.** A per-game queue is sufficient
|
||||
and there is nothing cleverer to do at this scale. Note this is a serialisation rule, not a
|
||||
one-actor-at-a-time rule: per-player turn state (`turns: Map<PlayerIndex, TurnState>`) means several
|
||||
players may hold an open turn at once, and their intents still land one at a time.
|
||||
|
||||
@@ -45,6 +45,7 @@ must do.
|
||||
| [`architecture/protocol.md`](architecture/protocol.md) | Intents, events, and per-player view redaction. |
|
||||
| [`architecture/lobby-and-sessions.md`](architecture/lobby-and-sessions.md) | Create/join, seating, reconnection, persistence. |
|
||||
| [`architecture/deployment.md`](architecture/deployment.md) | Plain web host vs StartOS service — and the one constraint that differs. |
|
||||
| [`architecture/multiplayer.md`](architecture/multiplayer.md) | **The multiplayer build plan.** The authoritative server as a layer added on top, with solitaire unchanged and server-free — plus every decision taken, listed for review. |
|
||||
|
||||
## Current status
|
||||
|
||||
|
||||
Reference in New Issue
Block a user