docs(backend): add multiplayer protocol spec
This commit is contained in:
parent
a58b1512fc
commit
629f52caa3
1 changed files with 405 additions and 0 deletions
405
backend/src/PROTOCOL.md
Normal file
405
backend/src/PROTOCOL.md
Normal file
|
|
@ -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<CategoryId, string>, 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<playerId, Record<CategoryId, string>>, 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.
|
||||
Loading…
Reference in a new issue