Playing a game
From “the clock starts” to “the result is applied”: the snapshot, the per-move loop, server clocks, promotion, draw negotiation, game end, spectating, and on-demand analysis.
game_state — the snapshot
Everything starts from game_state: it is seat-relative (each player gets their own — you and youAccepted differ) and total (it replaces the client's entire view of that room). Whenever one arrives, rebuild the whole board UI from it.
// game_state — the full snapshot of one room. Sent when your own game is pushed
// (match start / reconnect), on subscribe, and re-broadcast on every gate accept.
{
"type": "game_state",
"room": "match_abc",
"fen": "<current position>",
"turn": "white",
"you": "white", // YOUR seat — differs per recipient ("spectator" when watching)
"moves": ["e2e4", "e7e5"], // full history (UCI)
"status": "active", // active | finished
"whiteMs": 180000,
"blackMs": 178687, // remaining clock, ms, server-authoritative
"started": true, // false ⇒ the presence gate still holds the clock
"gate": "ready_check", // ready_check | grace_period | none
"youAccepted": true, // this recipient confirmed the gate
"gateEndsAt": 1719828030000, // unix ms, present only while !started
"serverNow": 1719828000000, // unix ms at send — age the clocks against it
"variant": "standard",
"timeControl": "blitz_3_2", // "" for a custom control
"players": [ // match identity — same seat shape as match_found
{ "userId": "user_123", "username": "alice", "color": "white", "rating": 1512 },
{ "userId": "user_456", "username": "bob", "color": "black", "rating": 1498 }
]
}
// game_state is seat-relative AND total: it replaces the client's whole view.
// Rebuild everything from it — board, seat, clocks, history, gate UI, opponent
// panel: players repeats what match_found said (username + isBot/botLevel included),
// so a reconnect after a page reload can render the opponent — by name — from this
// alone.The move loop
- ♟️User drags e2 → e4
- 📤SDK sends
client.move("e2e4") - ✔️validates turn + legality · ticks clock · switches turn
- 📥SDK fires
on("move") → render fen + clocks
// The move loop. The client sends INTENT; the board changes only when the
// engine broadcasts the validated move back:
client.move("e2e4");
client.on("move", (m) => {
render(m.fen); // position after the move
setTurn(m.turn); // side to move next
setClocks(m.whiteMs, m.blackMs); // server clocks — replace, never accumulate
});
// A rejected move never changes the board — you get an error instead:
client.on("error", (e) => {
switch (e.code) {
case "not_started": return showGateUI(); // gate not open yet
case "not_your_turn": return flashBoard();
case "illegal_move": return snapPieceBack();
case "invalid_move": return snapPieceBack(); // malformed UCI string
}
});Live: the client sends UCI intent, the engine broadcasts validated state — captures included:
Clocks
Clocks are milliseconds remaining, server-authoritative. Tick locally for smooth display, but the truth always arrives from the engine — on every move, and between moves via room pings.
// Clocks: tick LOCALLY for display, but treat every server value as the truth.
// Each move carries fresh whiteMs/blackMs. Between moves the engine pings each
// room (~5s) with a clock resync — so a long think or a throttled background
// tab never drifts far:
{ "type": "ping", "t": 1720000000, "room": "match_abc", "whiteMs": 179650, "blackMs": 178900 }
// The SDK answers pings automatically (that echo feeds lag compensation — the
// engine subtracts network delay from thinking time, capped per time control).
// The session-level ping (no room) carries t only.
client.on("message", (m) => { // resync hook: room pings carry clocks
if (m.type === "ping" && m.room) setClocks(m.whiteMs, m.blackMs);
});
// "started" is exactly "the clocks are running" — gate your countdown on it.Promotion
// Promotion is a plain move with a 5-character UCI — the 4 squares plus the
// piece letter q · r · b · n (e.g. "e7e8q"). The SDK does NOT auto-append;
// your UI picks the piece and the engine validates it.
import { parseBoard, isPromotion } from "@inachess/sdk";
let board = {};
client.on("gameState", (s) => (board = parseBoard(s.fen)));
client.on("move", (m) => (board = parseBoard(m.fen)));
async function onDrop(from, to) { // from your board UI, e.g. "e7" → "e8"
if (isPromotion(board, from, to)) {
const piece = await askPlayer(); // "q" | "r" | "b" | "n" ← your promotion dialog
client.move(from + to + piece); // e.g. "e7e8q"
} else {
client.move(from + to); // e.g. "e2e4"
}
}
// Missing/illegal promotion letter → rejected with illegal_move.Live: the pawn reaches the last rank, a chooser appears, the picked piece is placed — the choice cycles q → r → b → n each loop:
Draws, resign, abort
// Draw negotiation & endings — all socket intents on the current game:
client.resign(); // any time
client.offerDraw(); // rate-limited: a move must pass between offers
client.acceptDraw(); // accept the pending offer → draw (draw_agreement)
client.declineDraw(); // decline it
client.claimDraw(); // claim threefold / 50-move WHEN eligible (else no_draw_to_claim)
client.abort(); // only before both sides' first move (else too_late_to_abort)
client.on("drawOffer", (d) => showDrawDialog(d.by)); // opponent offered
client.on("drawDeclined", () => toast("Draw declined"));
// Automatic draws need no claim: fivefold repetition, 75-move rule,
// stalemate, and insufficient material end the game by themselves.Game end
- 🏁checkmate / resign / timeout / draw
- 📥SDK fires
on("gameOver") - 📨engine POSTs
game_result → your webhook
// game_over ends ONE room — the session socket stays open for the next game.
{ "type": "game_over", "room": "match_abc", "result": "white_win", "reason": "checkmate" }
// result: white_win | black_win | draw | aborted
// reason: checkmate · resign · timeout · stalemate · insufficient · threefold ·
// fifty_move · draw_agreement · no_show · aborted · …
client.on("gameOver", (g) => {
showResult(g.result, g.reason);
// The authoritative rating change arrives at the PLATFORM via game_result —
// fetch/refresh the user's profile from your own backend to display it.
});✅ The next game for that user is only allowed after the platform has applied the previous result (sequential rating consistency) — the engine holds the busy-state until then. If a user seems “stuck”, check that your webhook acknowledged their last game_result.
Spectating
// Watching a match is a subscribe on the SAME session socket — no extra
// connection, no ticket. Any live match may be watched (engine default: open).
client.subscribe("match_abc"); // → game_state (you: "spectator"), then move broadcasts
// its players[] is how a spectator learns who is playing
client.unsubscribe("match_abc"); // stop watching
// The same pair addresses your OWN board:
client.subscribe(); // no room = re-attach to your own game
client.unsubscribe(myRoom); // tells the engine you LEFT the board (presence!)
// Spectator broadcasts are delayed by spectatorDelaySec (0 for regular games,
// >0 for tournament games so preparation can't be snooped). A per-game
// spectator cap applies → error code spectator_limit when full. Spectators
// can never send moves, and spectate subscriptions are NOT restored after a
// reconnect — re-subscribe on "open".Presence lives here: subscribing to your own room means “at the board”, unsubscribing means “left it” — that is what the tournament grace gate and no-show detection read. The authorisation default is open: if you need to restrict who may watch what, that policy has to be stated to the engine side.
After the game — review & history
GET /v1/matches/{id} returns state / result / UCI move list for history views. For analysis, request the on-demand review:
GET /v1/matches/{id}/review
X-Service-Auth: <secret>
# On-demand post-game analysis (Stockfish) — generated once, then permanent.
# First call kicks off async generation:
# 202 — not ready yet; poll until 200
{ "success": true, "response": "success", "responseCode": 202,
"data": { "status": "generating", "matchId": "match_abc" } }
# 200 — the finished review
{
"success": true, "response": "success", "responseCode": 200,
"data": {
"matchId": "match_abc",
"engine": "Stockfish", "depth": 18,
"white": { "accuracy": 93.1, "estRating": 1858,
"counts": { "brilliant": 0, "great": 0, "book": 3, "best": 0, "excellent": 0,
"good": 1, "inaccuracy": 0, "mistake": 0, "miss": 0, "blunder": 0 } },
"black": { "accuracy": 68.1, "estRating": 983, "counts": { /* same 10 keys */ } },
"moves": [
{ "ply": 1, "by": "white", "uci": "e2e4", "best": "e2e4",
"cpWhite": 43, "cpLoss": 0, "classification": "book" },
{ "ply": 6, "by": "black", "uci": "g8f6", "best": "g7g6",
"cpWhite": 1000, "cpLoss": 10025, "classification": "blunder" }
],
"evalGraph": [ { "ply": 1, "cp": 43 }, { "ply": 2, "cp": 34 } ]
}
}
# 503 (success:false) if Stockfish is unavailable.
# accuracy / estRating / classification are heuristics (win%-model,
# chess.com-style) — an approximation, not a replica. Not piggybacked on
# game_result: fetch only when a user opens the analysis view.What can go wrong
| Symptom | Cause & fix |
|---|---|
| Moves rejected with not_started | The presence gate hasn't opened — status "active" only means the game exists. Gate your board on started, show the ready UI instead. |
| error code not_your_turn / illegal_move / invalid_move | Ordinary rejections — snap the piece back. The board only ever changes on a broadcast move. |
| error code draw_too_soon | Draw offers are rate-limited: a move must pass between offers. Disable the offer button until the next move. |
| error code no_draw_to_claim | claimDraw() sent while threefold/50-move isn't actually on the board. Only enable the claim button when your own move list says it is. |
| error code too_late_to_abort | abort() after both sides moved. Offer resign instead. |
| Clock jumps after a long think | Working as designed — your local countdown drifted (throttled tab) and a room ping resynced it. Always replace clocks with server values, never accumulate. |
| Disconnected mid-game | Clock keeps running (server-authoritative). The SDK reconnects with the same token and the engine re-pushes game_state — match identity (variant, timeControl, players) included, so the opponent panel renders even after a full page reload. See Sessions & connection. |
| Opponent disconnected and nothing happens | Their clock is still running — they simply lose on time if they never return. Show a “opponent disconnected” hint from their missing presence if you track it. |