From 629f52caa335f8ebb0bbab1f4a97d84f66684d30 Mon Sep 17 00:00:00 2001 From: Gabriel Franco Date: Thu, 10 Sep 2026 11:45:28 -0300 Subject: [PATCH] docs(backend): add multiplayer protocol spec --- backend/src/PROTOCOL.md | 405 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 backend/src/PROTOCOL.md diff --git a/backend/src/PROTOCOL.md b/backend/src/PROTOCOL.md new file mode 100644 index 0000000..b25f84a --- /dev/null +++ b/backend/src/PROTOCOL.md @@ -0,0 +1,405 @@ +# Multiplayer WebSocket Protocol + +Status: **spec only** — nothing in this repo implements it yet. This +document is the contract `backend/src/matchmaking/`, `backend/src/rooms/`, +`backend/src/ws/`, and the (not-yet-written) frontend WS client must agree +on before any of that code is written, so that both sides can be built +independently without guessing at each other's message shapes. + +`frontend/` and `backend/` deliberately have no shared package/tooling +(see `AGENTS.md`), so there is no shared TypeScript module either side +imports types from. This file is that shared contract, kept in sync by +hand. Type names below (`CategoryId`, `ThemeId`, ...) refer to +`frontend/src/types.ts`; the backend never imports them, it just needs to +pass the string values through untouched. + +## Transport + +One `ws://` connection per player, one JSON object per text frame. Every +message is a flat object with a `type` string discriminator plus +message-specific fields — no envelope wrapper, no request/response ids. +`type` values use `namespace:event` naming (`queue:join`, `room:created`, +...) matching the namespaces below. + +## Identity + +There is no account system and none is planned — adding auth is out of +scope for multiplayer entirely. Instead, the client generates a +`playerId` (a `crypto.randomUUID()` v4 string) the first time it loads, +persists it in `localStorage`, and reuses it on every future visit. The +player also picks/keeps a display `name` (also persisted client-side, no +server-side uniqueness check). The very first message a client sends +after the socket opens registers both with the server: + +```jsonc +// client -> server, first message on every connection +{ "type": "identify", "playerId": "3f1b2c8e-...-uuid", "name": "gabe" } + +// server -> client +{ "type": "identify:ack" } +``` + +`playerId` is **client-informational, not authenticated** — the server +trusts whatever UUID a socket presents. It exists purely as a stable key +for two things that need to survive a reconnect: the matchmaking-ban +table (see Quit below) and, for the process lifetime of a match, telling +two sockets in the same room apart. It grants no privileges and proves no +identity; a client that forges another player's UUID could at most get +that player's matchmaking ban lifted early, which is judged an acceptable +risk for a casual game with no accounts. + +No other message is valid before `identify`; the server closes the +socket if anything else arrives first. + +## Round lifecycle (shared by matchmaking and rooms) + +Both matchmaking matches and private-room matches run the same +round state machine once two players are paired. `timeMode` (`1` or +`2`, minutes) and the `themeId` for the round are decided upstream +(randomly for matchmaking, per room config for rooms) before +`round:start` fires. + +```mermaid +stateDiagram-v2 + [*] --> RoundStart + RoundStart --> Painting: round:start + Painting --> Painting: round:progress (either direction, repeatable) + Painting --> Warning: round:timeWarning (t = endsAt - 20s) + Warning --> Warning: round:progress + Warning --> Submitted: round:submit (per player, 0-2 times) + Submitted --> Reveal: endsAt reached + Reveal --> [*]: round:reveal +``` + +- **`round:start`** — server -> both players. + ```jsonc + { + "type": "round:start", + "snippetIndex": 2, // server-authoritative, see Snippet identity contract below + "themeId": "tokyoNight", // ThemeId; server-authoritative + "timeMode": 2, // 1 | 2 (minutes); server-authoritative + "endsAt": 1757500000000 // epoch ms; server-authoritative round-end deadline + } + ``` + `endsAt` is computed server-side (`now + timeMode * 60_000`) and is the + single source of truth for round timing; clients render a countdown + from it but never decide when the round ends themselves. + +- **`round:progress`** — client -> server -> opponent, fired every time a + player assigns a color to a category while painting. + ```jsonc + { "type": "round:progress", "categoryId": "keyword" } + ``` + This carries **only** the `CategoryId` that changed, never a color + value. The sending client decides what to transmit — it never puts a + hex string on the wire here — and the server has nothing to strip even + if it wanted to; it just relays the `categoryId` to the other socket + in the match. This is the mechanism the opponent's split-view "they've + filled in N of 12 categories" progress UI depends on: it can show + *which* swatch the opponent touched without ever learning *what color* + they picked. Raw colors do not leave the color-picking client until + `round:submit`. + +- **`round:timeWarning`** — server -> both players, fired exactly once + per round, when 20 seconds remain before `endsAt`. + ```jsonc + { "type": "round:timeWarning" } + ``` + +- **`round:submit`** — client -> server, sent by each player once, right + before or exactly at time-up, with their final full guess. + ```jsonc + { + "type": "round:submit", + "colors": { + "background": "#1a1b26", "keyword": "#bb9af7", "string": "#9ece6a", + "number": "#ff9e64", "constant": "#ff9e64", "function": "#7aa2f7", + "variable": "#c0caf5", "property": "#7aa2f7", "type": "#2ac3de", + "operator": "#89ddff", "punctuation": "#c0caf5", "comment": "#565f89" + } // Record, i.e. a plain hex-value CategoryStateMap projection + } + ``` + `colors` is client-informational input — whatever the sending player's + local `CategoryStateMap.assignedHex` values were at submit time, + unvalidated by the server. A player who never submits (ran out of + time without finishing) is scored locally by their own client using + whatever `assignedHex` values were set (including `null` for + untouched categories — `computeMatchResult` already treats a `null` + guess as zero similarity); the server does not synthesize a submission + for them, it simply reveals whatever last `round:submit` it received + (or none, in which case that player's row in `round:reveal` is + omitted and the receiving client treats it the same as an + all-`null` map). + +- **`round:reveal`** — server -> both players, sent only once `endsAt` + has passed (never early, even if both players already submitted) so + neither client can infer the opponent's picks before the round is + officially over. + ```jsonc + { + "type": "round:reveal", + "colors": { + "3f1b2c8e-...-uuid": { "background": "#1a1b26", "keyword": "#bb9af7", ... }, + "9a7d0e11-...-uuid": { "background": "#181818", "keyword": "#fabd2f", ... } + } // Record>, keyed by both players' playerId + } + ``` + Both players receive the identical message and use their own + `playerId` to pick out which entry is "mine" vs "theirs". + +See "Scoring stays client-side" below for what each client does with this. + +Private rooms additionally get: + +- **`round:nextMatch`** — server -> both players, sent after a + `round:reveal` in a private room if the series hasn't hit its 5-match + cap, immediately followed by the next `round:start`. + ```jsonc + { "type": "round:nextMatch", "match": 3 } // 1-5, the match about to start + ``` + +## Matchmaking + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Queued: queue:join + Idle --> Banned: queue:join (while banned) -> queue:banned + Banned --> Idle + Queued --> Idle: queue:leave + Queued --> Matched: match:found (both players) + Matched --> [*]: round lifecycle runs, then match:found again for the next opponent or Idle if the player leaves +``` + +- **`queue:join`** — client -> server. No mode parameter: the server + randomly assigns 1-minute or 2-minute mode per match (each queued + player doesn't pick, so pairing isn't constrained by mode preference). + ```jsonc + { "type": "queue:join" } + ``` +- **`queue:leave`** — client -> server, removes the player from the + queue if still waiting; a no-op reply is not sent (leaving is + client-authoritative fire-and-forget; the server just stops matching + that socket). + ```jsonc + { "type": "queue:leave" } + ``` +- **`queue:banned`** — server -> client, sent instead of queuing the + player when their `playerId` has an active matchmaking ban (see + Quit below). + ```jsonc + { "type": "queue:banned", "bannedUntil": 1757500120000 } // epoch ms; server-authoritative + ``` +- **`match:found`** — server -> both matched players, the transition out + of the queue. A `round:start` for match 1 follows immediately; there is + no matchmaking equivalent of a room code or lobby. + ```jsonc + { "type": "match:found", "opponentName": "ada", "timeMode": 1 } + ``` + +Matchmaking matches are single-round (no best-of-5, no `round:nextMatch`) +— after `round:reveal` both players return to `Idle` and may `queue:join` +again. + +## Private rooms + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> AwaitingOpponent: room:create -> room:created + Idle --> RoomError: room:join (bad/full code) -> room:error + Idle --> InRoom: room:join (valid code) -> room:joined + AwaitingOpponent --> InRoom: opponent's room:join -> room:joined (both) + InRoom --> InRoom: round lifecycle (x1-5, round:nextMatch between) + InRoom --> Closed: room:closed (matchLimit | finished | quit) + Closed --> [*] +``` + +- **`room:create`** — client -> server, creates a room and puts the + creator in it waiting for an opponent. + ```jsonc + { "type": "room:create", "timeMode": 2, "themeMode": "chosen" } + // timeMode: 1 | 2 (minutes); themeMode: 'random' | 'chosen' + ``` +- **`room:created`** — server -> creator, with a short numeric code to + share out-of-band with the intended opponent. + ```jsonc + { "type": "room:created", "code": 483920 } // server-generated, server-authoritative + ``` +- **`room:join`** — client -> server, attempts to join an existing room + by code. + ```jsonc + { "type": "room:join", "code": 483920 } + ``` +- **`room:joined`** — server -> both players, once a second player joins + a room (or, for the creator, once someone else joins theirs). + ```jsonc + { + "type": "room:joined", + "code": 483920, + "opponentName": "ada", + "timeMode": 2, + "themeMode": "chosen" + } + ``` + If `themeMode` is `'chosen'`, theme voting (below) runs before the + first `round:start`; if `'random'`, the server picks a `themeId` + itself and goes straight to `round:start`. +- **`room:error`** — server -> the joining client only, when `room:join` + fails. + ```jsonc + { "type": "room:error", "reason": "not_found" } // 'not_found' | 'full' + ``` +- **`room:playerLeft`** — server -> the remaining player, sent as soon as + the server learns the opponent is gone (explicit `player:quit` or a + dropped socket), before the room is torn down. + ```jsonc + { "type": "room:playerLeft" } + ``` +- **`room:closed`** — server -> whichever player(s) are still connected, + finalizes the room and frees its code for reuse. Always the terminal + message for a room. + ```jsonc + { "type": "room:closed", "reason": "matchLimit" } // 'quit' | 'matchLimit' | 'finished' + ``` + - `quit` — `room:playerLeft` just fired; a player quit or disconnected + before the 5-match series completed. + - `matchLimit` — match 5 of 5's `round:reveal` was just delivered and + the series cap was reached; the server closes the room on its own, + no client action required. + - `finished` — both players are done looking at the last result and + one of them explicitly leaves the room via the post-game UI (a + normal, non-quit exit after `matchLimit` would otherwise leave the + room open for a rematch that never comes). + +## Theme voting + +Only runs for private rooms created with `themeMode: 'chosen'`, once +after `room:joined` and before match 1's `round:start` (not repeated for +matches 2-5 — the voted theme is reused for the whole 5-match series). + +```mermaid +stateDiagram-v2 + [*] --> Voting: vote:start + Voting --> Voting: vote:cast (either player) -> vote:opponentChoice (relayed) + Voting --> Settled: endsAt reached -> vote:settled + Settled --> [*]: round:start (match 1) +``` + +- **`vote:start`** — server -> both players, a 10-second countdown to + pick a theme. + ```jsonc + { + "type": "vote:start", + "themeIds": ["gruvbox", "tokyoNight", "vscode", "github", "dracula", "catppuccin"], + "endsAt": 1757500010000 // epoch ms, server-authoritative; always start + 10s + } + ``` + `themeIds` is every key of the frontend's `THEMES` catalog + (`frontend/src/data/themes.ts`) — the server doesn't know theme + contents, only that it must offer the full set both clients ship. +- **`vote:cast`** — client -> server, may be sent more than once before + `endsAt` (a later cast from the same player overrides their earlier + one); ignored if received after `endsAt`. + ```jsonc + { "type": "vote:cast", "themeId": "tokyoNight" } + ``` +- **`vote:opponentChoice`** — server -> the *other* player, relayed live + each time a `vote:cast` is accepted, so both players can see a live + "they're leaning towards..." indicator without waiting for settlement. + ```jsonc + { "type": "vote:opponentChoice", "themeId": "tokyoNight" } + ``` +- **`vote:settled`** — server -> both players, fired only once the 10s + countdown (`endsAt`) elapses — never early, even if both players cast + matching votes well before the deadline, so neither client can act on + the outcome before voting is officially closed. + ```jsonc + { "type": "vote:settled", "themeId": "tokyoNight", "agreed": true } + ``` + Resolution rule, applied server-side once `endsAt` passes: + - Both players cast the same `themeId` -> that theme, `agreed: true`. + - Both cast, but differ -> server picks one of the two uniformly at + random, `agreed: false`. + - Only one player cast -> that player's `themeId`, `agreed: false`. + - Neither cast -> a `themeId` chosen uniformly at random from + `themeIds`, `agreed: false`. + +## Quit + +- **`player:quit`** — client -> server, an explicit "leave" action (also + triggered server-side by an unexpected socket close, e.g. tab closed). + Same message, in-flight round or not; the server infers everything + else it needs (which match/room the socket was in) from its own + connection state. + ```jsonc + { "type": "player:quit" } + ``` + + Consequences differ by context, both delivered via the room-lifecycle + messages above (`player:quit` itself has no direct reply): + + - **Matchmaking match**: opponent gets `room:playerLeft` then + `room:closed` (`reason: "quit"`). The quitter's `playerId` is + additionally recorded with a matchmaking ban expiring 2 minutes from + now; the *next* time that `playerId` sends `queue:join` (from any + socket/reconnect) before that expiry, the server replies + `queue:banned` with the stored `bannedUntil` instead of queuing them. + Private-room matches never consult or write this ban table. + - **Private room**: opponent gets `room:playerLeft` then `room:closed` + (`reason: "quit"`). No ban, no penalty of any kind — either player + can immediately create or join another room. + +## Snippet identity contract + +`round:start.snippetIndex` is a plain integer index into the frontend's +*local* snippet catalog (the eventual multi-snippet successor to today's +single hardcoded `CODE_SAMPLE` in `frontend/src/data/themes.ts`) — the +server never sends snippet source, tokens, or any other snippet content, +only the index. + +**Invariant**: every connected client must ship the exact same +build/catalog ordering, so that `snippetIndex` means the same snippet on +both ends of a match. This is enforced by deployment discipline (both +players load the same frontend build), not by anything in this protocol +— there is no version-negotiation message. A future addition to this +spec if snippet catalog drift ever becomes possible. + +Because of that invariant, the backend does not need — and must not +hardcode — snippet content or count. It only needs the catalog's +**length**, to pick a random valid `snippetIndex`. That length is an +env-configurable constant (e.g. `SNIPPET_CATALOG_LENGTH`, default `6`, +alongside `PORT` in `backend/.env.example`), bumped whenever the frontend +catalog grows, not derived automatically (the two packages share no +build step that could compute it). + +## Scoring stays client-side + +`computeMatchResult` (`frontend/src/game/scoring.ts`) — which weights +each category's CIELAB perceptual similarity +(`similarityFromHex`/`deltaE76` in `frontend/src/engine/colorUtils.ts`) +by how often that token kind appears in the snippet — is the **only** +implementation of match scoring, and it stays entirely client-side. The +server never runs it, never receives enough information to run it (it +doesn't know each theme's `actualHex` values, only `themeId`), and +`round:reveal` carries pre-computed nothing — just both players' raw +`assignedHex` maps. + +Once `round:reveal` arrives, each client: + +1. Looks up the round's theme in its own local `THEMES[themeId]` catalog + to get `actualHex` per category (the server already told it + `themeId` back in `round:start`). +2. Calls `computeMatchResult` once against its own already-local + `CategoryStateMap` (built live while painting) to get its own + `MatchResult`. +3. Calls `computeMatchResult` again, this time against a + `CategoryStateMap` reconstructed from the *opponent's* entry in + `round:reveal.colors` (same `actualHex` values, `assignedHex` filled + in from the opponent's revealed guesses) to get the opponent's + `MatchResult`. + +Both players' clients run the identical function against identical +inputs and therefore land on identical `MatchResult.overall` scores for +both sides — there is no server-side scoring pass to keep in sync with +`computeMatchResult`, and no reason to ever add one.