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.

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

  1. ♟️User drags e2 → e4
  2. 📤SDK sendsclient.move("e2e4")
  3. ✔️validates turn + legality · ticks clock · switches turn
  4. 📥SDK fireson("move") → render fen + clocks
ts
// 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:

User BBlack ♚
♜
♞
♝
♛
♚
♝
♞
♜
♟
♟
♟
♟
♟
♟
♟
♟
♟
♟
♟
♟
♟
♟
♟
♟
♜
♞
♝
♛
♚
♝
♞
♜
LIVE
User AWhite ♔to move
// the per-move loopUser A · WhiteUser B · BlackAclient.move("e2e4");Bclient.move("e7e5");Aclient.move("g1f3");Bclient.move("b8c6");Aclient.move("f1c4");Bclient.move("g8f6");Aclient.move("f3e5");Bclient.move("c6e5");▶ looping · pure HTML/CSS/JS · A = white seat, B = black seat

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.

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

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

User BBlack ♚
♚
♟
♚
LIVE
User AWhite ♔to move
// promotion — the 5th UCI char is the chosen pieceUser A · WhiteUser B · BlackAclient.move("a5a6");Bclient.move("g8f8");Aclient.move("a6a7");Bclient.move("f8g8");Aclient.move("a7a8q"); // promote → ♛▶ looping · pure HTML/CSS/JS · A = white seat, B = black seat

Draws, resign, abort

ts
// 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

  1. 🏁checkmate / resign / timeout / draw
  2. 📥SDK fireson("gameOver")
  3. 📨engine POSTsgame_result → your webhook
jsonc
// 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

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

http
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

SymptomCause & fix
Moves rejected with not_startedThe 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_moveOrdinary rejections — snap the piece back. The board only ever changes on a broadcast move.
error code draw_too_soonDraw offers are rate-limited: a move must pass between offers. Disable the offer button until the next move.
error code no_draw_to_claimclaimDraw() 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_abortabort() after both sides moved. Offer resign instead.
Clock jumps after a long thinkWorking as designed — your local countdown drifted (throttled tab) and a room ping resynced it. Always replace clocks with server values, never accumulate.
Disconnected mid-gameClock 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 happensTheir 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.