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

http
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.

FieldTypeDescription
roomstringmatch id this frame belongs to
fenstringcurrent position (FEN)
turn"white" | "black"side to move
you"white" | "black" | "spectator"the recipient's seat
movesstring[]full move history (UCI)
status"active" | "finished""active" only means the game exists; moves are accepted only once started is true
whiteMs / blackMsnumberremaining clock in ms, server-authoritative
startedbooleantrue = the clocks are running; false = the presence gate still holds
gate"ready_check" | "grace_period" | "none"which presence gate applies to this game
youAcceptedbooleanthis recipient has confirmed the ready_check
gateEndsAtnumberunix ms no-show deadline; present only while started is false
serverNownumberunix ms at send — age the clocks against it
variantstringvariant of the game, e.g. "standard"
timeControlstringtime-control id, e.g. "blitz_3_2"; "" for a custom control
playersMatchSeat[]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.

FieldTypeDescription
roomstringmatch id
ucistringthe move played (UCI)
by"white" | "black"color that moved
fenstringposition after the move
turn"white" | "black"side to move next
whiteMs / blackMsnumberremaining 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.

FieldTypeDescription
matchIdstringthe room id — subscribe target / route to the board
variantstringvariant of the game, e.g. "standard"
timeControlstringtime-control id, e.g. "blitz_3_2"
players[].userIdstringone entry per seat; the bot seat is "stockfish"
players[].usernamestringplatform 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[].ratingnumbermatch-time rating snapshot — display info only, never authoritative; omitted for bots or when unknown
players[].isBotbooleanpresent (true) when the seat is Stockfish
players[].botLevelnumberStockfish skill 1..8; present only with isBot

WS game_over

the room finished — terminal for that room only, the session socket stays open.

FieldTypeDescription
roomstringmatch id
result"white_win" | "black_win" | "draw" | "aborted"outcome
reasonstringcheckmate · resign · timeout · stalemate · insufficient · threefold · fifty_move · draw_agreement · no_show · aborted · …

WS draw_offer

the opponent proposed a draw.

FieldTypeDescription
roomstringmatch id
by"white" | "black"color that offered

WS draw_declined

your draw offer was declined.

FieldTypeDescription
roomstringmatch id

WS error

a rejected command, sent only to the offending client. Branch on code — message is human text and may change.

FieldTypeDescription
roomstringmatch id; omitted when the failure is session-scoped (queue, play_bot, subscribe …)
codestringstable identifier — full list in Errors & codes
messagestringhuman text; never branch on it

WS ping

lag probe + periodic clock resync (~5s per room). The SDK answers automatically with a pong echoing t.

FieldTypeDescription
tnumberserver timestamp — echo it back verbatim in a pong intent
roomstringpresent on a room ping; omitted on the session-level ping
whiteMs / blackMsnumberclock resync values; present on a room ping only

Client → engine

jsonc
// 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:

IntentParamsPurpose
moveuci: 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
pongt: 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.

FieldTypeRequiredDefaultDescription
type"queue"yes—enter matchmaking
variantstringyes—from GET /v1/game-types
timeControlstringyes—a time-control id from GET /v1/game-types
ratedboolean—truetrue = rated pool · false = casual FIFO pool
autoBotboolean—truetrue = ~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):

FieldTypeRequiredDefaultDescription
type"subscribe"yes—watch a match / re-attach to your own game
roomstring—own gamematch id to watch; omit to re-attach to your own game
FieldTypeRequiredDefaultDescription
type"unsubscribe"yes—stop watching — on your OWN room this means “I left the board” (presence)
roomstringyes—match id

play_bot — no Stockfish available → bots_unavailable:

FieldTypeRequiredDefaultDescription
type"play_bot"yes—start a game vs Stockfish — resolves immediately, no gate, always unrated
levelnumber—4skill 1..8; out-of-range clamps
timeControlstringyes—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 move frame would silently desync the board, so it never happens.
  • Reconnect restores your own game only. The pushed game_state carries the full match identity (variant, timeControl, players) — no state from the original match_found is needed. Spectate subscriptions and queue entries are not restored — re-request them on open.
  • Every rejection is an error with a stable code. Full list + handling: Errors & codes.