WebSocket wire reference
The raw wire, field by field, both directions. One socket per login session carries the lobby, the player's own game, and any spectated games — every game-scoped message is addressed by room.
Handshake & close codes
wss://engine.example.com/ws
Sec-WebSocket-Protocol: inachess.v2, <sessionToken>
# One socket per login session. The token travels in the subprotocol list —
# never in the URL — and is validated BEFORE the upgrade: a bad/expired token
# is refused at the handshake, never as a message.
# Close codes: 4001 token expired (re-mint, don't retry) · 4002 replaced by the
# user's own newer socket (don't retry).Server → client
WS game_state
full snapshot of one room — replaces the client's whole view. Sent on own-game push (match start / reconnect), on subscribe, and re-broadcast on every gate accept. Seat-relative: you / youAccepted differ per recipient. Also carries the match identity (variant, timeControl, players — the exact seat shape of match_found), so a client that reconnects after a page reload or device switch, or a spectator who never got a match_found, can still render the players.
| Field | Type | Description |
|---|---|---|
room | string | match id this frame belongs to |
fen | string | current position (FEN) |
turn | "white" | "black" | side to move |
you | "white" | "black" | "spectator" | the recipient's seat |
moves | string[] | full move history (UCI) |
status | "active" | "finished" | "active" only means the game exists; moves are accepted only once started is true |
whiteMs / blackMs | number | remaining clock in ms, server-authoritative |
started | boolean | true = the clocks are running; false = the presence gate still holds |
gate | "ready_check" | "grace_period" | "none" | which presence gate applies to this game |
youAccepted | boolean | this recipient has confirmed the ready_check |
gateEndsAt | number | unix ms no-show deadline; present only while started is false |
serverNow | number | unix ms at send — age the clocks against it |
variant | string | variant of the game, e.g. "standard" |
timeControl | string | time-control id, e.g. "blitz_3_2"; "" for a custom control |
players | MatchSeat[] | both seats, white first — the exact seat shape of match_found (userId, username?, color, rating?, isBot?, botLevel?) |
WS move
broadcast to the room after a move is validated.
| Field | Type | Description |
|---|---|---|
room | string | match id |
uci | string | the move played (UCI) |
by | "white" | "black" | color that moved |
fen | string | position after the move |
turn | "white" | "black" | side to move next |
whiteMs / blackMs | number | remaining clock in ms after the move |
WS match_found
pushed when the player is seated in a new game (matchmaking pair, bot fallback, tournament round), always before that room's first game_state. matchId is the room id to route to. The same identity (variant, timeControl, players) repeats in every game_state, so losing this message — page reload, device switch — loses nothing.
| Field | Type | Description |
|---|---|---|
matchId | string | the room id — subscribe target / route to the board |
variant | string | variant of the game, e.g. "standard" |
timeControl | string | time-control id, e.g. "blitz_3_2" |
players[].userId | string | one entry per seat; the bot seat is "stockfish" |
players[].username | string | platform display name for the seat — render the opponent by it; omitted for a bot seat or when the platform lookup was unavailable |
players[].color | "white" | "black" | seat color |
players[].rating | number | match-time rating snapshot — display info only, never authoritative; omitted for bots or when unknown |
players[].isBot | boolean | present (true) when the seat is Stockfish |
players[].botLevel | number | Stockfish skill 1..8; present only with isBot |
WS game_over
the room finished — terminal for that room only, the session socket stays open.
| Field | Type | Description |
|---|---|---|
room | string | match id |
result | "white_win" | "black_win" | "draw" | "aborted" | outcome |
reason | string | checkmate · resign · timeout · stalemate · insufficient · threefold · fifty_move · draw_agreement · no_show · aborted · … |
WS draw_offer
the opponent proposed a draw.
| Field | Type | Description |
|---|---|---|
room | string | match id |
by | "white" | "black" | color that offered |
WS draw_declined
your draw offer was declined.
| Field | Type | Description |
|---|---|---|
room | string | match id |
WS error
a rejected command, sent only to the offending client. Branch on code — message is human text and may change.
| Field | Type | Description |
|---|---|---|
room | string | match id; omitted when the failure is session-scoped (queue, play_bot, subscribe …) |
code | string | stable identifier — full list in Errors & codes |
message | string | human text; never branch on it |
WS ping
lag probe + periodic clock resync (~5s per room). The SDK answers automatically with a pong echoing t.
| Field | Type | Description |
|---|---|---|
t | number | server timestamp — echo it back verbatim in a pong intent |
room | string | present on a room ping; omitted on the session-level ping |
whiteMs / blackMs | number | clock resync values; present on a room ping only |
Client → engine
// Game intents carry NO room — the engine resolves it from the player:
{ "type": "move", "uci": "e2e4" }
{ "type": "resign" }
{ "type": "offer_draw" } · { "type": "accept_draw" } · { "type": "decline_draw" }
{ "type": "claim_draw" } · { "type": "abort" } · { "type": "accept" }
{ "type": "pong", "t": 1720000000 }
// Session-scoped intents (lobby-level — they address no room):
{ "type": "queue", "variant": "standard", "timeControl": "blitz_3_2", "rated": true, "autoBot": true }
{ "type": "queue_cancel" }
{ "type": "subscribe", "room": "match_abc" } // omit room to re-attach to your own game
{ "type": "unsubscribe", "room": "match_abc" }
{ "type": "play_bot", "level": 5, "color": "white", "timeControl": "blitz_3_2" }Game intents
They carry no room — one active game per user, so the engine resolves the game from the player:
| Intent | Params | Purpose |
|---|---|---|
move | uci: string (required) | play a move; promotion = 5-char UCI, e.g. e7e8q (q · r · b · n) |
resign | — | resign the game |
offer_draw | — | propose a draw — rate-limited (≥1 move between offers → draw_too_soon) |
accept_draw | — | accept the opponent's pending draw offer |
decline_draw | — | decline the opponent's pending draw offer |
claim_draw | — | claim a threefold / 50-move draw when eligible (else no_draw_to_claim) |
abort | — | abort the game — only before both sides' first move (else too_late_to_abort) |
accept | — | confirm presence under a ready_check gate; the clock starts once both seats have |
pong | t: number (echo of ping.t) | reply to a server ping; the SDK sends it automatically |
Session intents
queue — never carries a rating (the engine pulls it); rate-limited per session; double enqueue → already_queued. queue_cancel takes no params.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
type | "queue" | yes | — | enter matchmaking |
variant | string | yes | — | from GET /v1/game-types |
timeControl | string | yes | — | a time-control id from GET /v1/game-types |
rated | boolean | — | true | true = rated pool · false = casual FIFO pool |
autoBot | boolean | — | true | true = ~30s unpaired → unrated bot game · false = wait for a human until the queue TTL (~90s) |
subscribe / unsubscribe — spectate is delayed by spectatorDelaySec and capped per game (spectator_limit):
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
type | "subscribe" | yes | — | watch a match / re-attach to your own game |
room | string | — | own game | match id to watch; omit to re-attach to your own game |
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
type | "unsubscribe" | yes | — | stop watching — on your OWN room this means “I left the board” (presence) |
room | string | yes | — | match id |
play_bot — no Stockfish available → bots_unavailable:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
type | "play_bot" | yes | — | start a game vs Stockfish — resolves immediately, no gate, always unrated |
level | number | — | 4 | skill 1..8; out-of-range clamps |
timeControl | string | yes | — | a time-control id from GET /v1/game-types |
color | "white" | "black" | — | "white" | the human's side |
Delivery guarantees on the socket
- Game frames are never shed. A saturated spectator stream is dropped first — a lost
moveframe would silently desync the board, so it never happens. - Reconnect restores your own game only. The pushed
game_statecarries the full match identity (variant,timeControl,players) — no state from the originalmatch_foundis needed. Spectate subscriptions and queue entries are not restored — re-request them onopen. - Every rejection is an
errorwith a stablecode. Full list + handling: Errors & codes.