17 KiB
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:
// 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.
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.{ "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 }endsAtis 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.{ "type": "round:progress", "categoryId": "keyword" }This carries only the
CategoryIdthat 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 thecategoryIdto 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 untilround:submit. -
round:timeWarning— server -> both players, fired exactly once per round, when 20 seconds remain beforeendsAt.{ "type": "round:timeWarning" } -
round:submit— client -> server, sent by each player once, right before or exactly at time-up, with their final full guess.{ "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 }colorsis client-informational input — whatever the sending player's localCategoryStateMap.assignedHexvalues 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 whateverassignedHexvalues were set (includingnullfor untouched categories —computeMatchResultalready treats anullguess as zero similarity); the server does not synthesize a submission for them, it simply reveals whatever lastround:submitit received (or none, in which case that player's row inround:revealis omitted and the receiving client treats it the same as an all-nullmap). -
round:reveal— server -> both players, sent only onceendsAthas passed (never early, even if both players already submitted) so neither client can infer the opponent's picks before the round is officially over.{ "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
playerIdto 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 around:revealin a private room if the series hasn't hit its 5-match cap, immediately followed by the nextround:start.{ "type": "round:nextMatch", "match": 3 } // 1-5, the match about to start
Matchmaking
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).{ "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).{ "type": "queue:leave" }queue:banned— server -> client, sent instead of queuing the player when theirplayerIdhas an active matchmaking ban (see Quit below).{ "type": "queue:banned", "bannedUntil": 1757500120000 } // epoch ms; server-authoritativematch:found— server -> both matched players, the transition out of the queue. Around:startfor match 1 follows immediately; there is no matchmaking equivalent of a room code or lobby.{ "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
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.{ "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.{ "type": "room:created", "code": 483920 } // server-generated, server-authoritativeroom:join— client -> server, attempts to join an existing room by code.{ "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).
If{ "type": "room:joined", "code": 483920, "opponentName": "ada", "timeMode": 2, "themeMode": "chosen" }themeModeis'chosen', theme voting (below) runs before the firstround:start; if'random', the server picks athemeIditself and goes straight toround:start.room:error— server -> the joining client only, whenroom:joinfails.{ "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 (explicitplayer:quitor a dropped socket), before the room is torn down.{ "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.{ "type": "room:closed", "reason": "matchLimit" } // 'quit' | 'matchLimit' | 'finished'quit—room:playerLeftjust fired; a player quit or disconnected before the 5-match series completed.matchLimit— match 5 of 5'sround:revealwas 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 aftermatchLimitwould 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).
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.{ "type": "vote:start", "themeIds": ["gruvbox", "tokyoNight", "vscode", "github", "dracula", "catppuccin"], "endsAt": 1757500010000 // epoch ms, server-authoritative; always start + 10s }themeIdsis every key of the frontend'sTHEMEScatalog (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 beforeendsAt(a later cast from the same player overrides their earlier one); ignored if received afterendsAt.{ "type": "vote:cast", "themeId": "tokyoNight" }vote:opponentChoice— server -> the other player, relayed live each time avote:castis accepted, so both players can see a live "they're leaning towards..." indicator without waiting for settlement.{ "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.
Resolution rule, applied server-side once{ "type": "vote:settled", "themeId": "tokyoNight", "agreed": true }endsAtpasses:- 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
themeIdchosen uniformly at random fromthemeIds,agreed: false.
- Both players cast the same
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.{ "type": "player:quit" }Consequences differ by context, both delivered via the room-lifecycle messages above (
player:quititself has no direct reply):- Matchmaking match: opponent gets
room:playerLeftthenroom:closed(reason: "quit"). The quitter'splayerIdis additionally recorded with a matchmaking ban expiring 2 minutes from now; the next time thatplayerIdsendsqueue:join(from any socket/reconnect) before that expiry, the server repliesqueue:bannedwith the storedbannedUntilinstead of queuing them. Private-room matches never consult or write this ban table. - Private room: opponent gets
room:playerLeftthenroom:closed(reason: "quit"). No ban, no penalty of any kind — either player can immediately create or join another room.
- Matchmaking match: opponent gets
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:
- Looks up the round's theme in its own local
THEMES[themeId]catalog to getactualHexper category (the server already told itthemeIdback inround:start). - Calls
computeMatchResultonce against its own already-localCategoryStateMap(built live while painting) to get its ownMatchResult. - Calls
computeMatchResultagain, this time against aCategoryStateMapreconstructed from the opponent's entry inround:reveal.colors(sameactualHexvalues,assignedHexfilled in from the opponent's revealed guesses) to get the opponent'sMatchResult.
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.