Use cases
Complete scenarios end to end — the happy path, the exact code, and every failure branch with its handling. Each case links back to the chapter that explains the mechanics.
Trace legend: user player action · platform your server · SDK client · engine engine-internal · callback engine → webhook.
- 1
Rated 1v1 — the core loop
Finding a game · Playing a game · Callbacks
- 👆Play · blitz 3|2
- 📤queue(rated:true)
- 🔎pulls your /users/{userId} → pool → pair
- 📥matchFound → route to board
- ✅both click Ready →
accept() - 🔁move loop
- 📨game_result → apply rating
tsclient.queue({ variant: "standard", timeControl: "blitz_3_2", rated: true }); client.on("matchFound", (m) => enterGame(m.matchId)); client.on("gameState", (s) => { render(s); if (!s.started && s.gate === "ready_check" && !s.youAccepted) client.accept(); }); client.on("move", (m) => render(m.fen, m.turn, m.whiteMs, m.blackMs)); client.on("gameOver", (g) => showResult(g.result, g.reason)); // Platform side: game_result arrives at the webhook → // ratings[ev.variant + ":" + ev.speed] = ratingAfter (clamp to floor), ACK 2xx.User BBlack ♚LIVE♜♞♝♛♚♝♞♜♟♟♟♟♟♟♟♟♟♟♟♟♟♟♟♟♜♞♝♛♚♝♞♜User AWhite ♔to move// rated blitz — queue → gate → play → resultUser A · WhiteUser B · BlackAclient.move("e2e4");Bclient.move("e7e5");Aclient.move("d1h5");Bclient.move("b8c6");Aclient.move("f1c4");Bclient.move("g8f6");Aclient.move("h5f7");▶ looping · pure HTML/CSS/JS · A = white seat, B = black seatIf it goes wrong Handling queue rejected: already_in_game Re-attach instead: client.subscribe() with no room — their active game's state is pushed back. queue rejected: platform_unavailable Your /users endpoint was slow/down. Show “try again” — by design there is no fallback. Opponent never accepts the gate ~30s → game_over (aborted / no_show) + match_aborted callback. THIS player is requeued automatically — show “finding a new opponent…”. game_result never seen Webhook down? The engine retries forever, and the reconciliation job picks it up. Nothing to do client-side. - 2
Casual / unrated game
ts// Casual: separate FIFO pool, no rating pull, no rating change. client.queue({ variant: "standard", timeControl: "blitz_3_2", rated: false }); // Its game_result arrives WITHOUT the rating block — record the game, done. - 3
Play against the computer
- 🤖vs computer · level 6
- 📤playBot({ level: 6 })
- 🏠room resolves immediately — no queue, no gate
- 📥matchFound (isBot seat) + gameState → play
tsclient.playBot({ level: 6, color: "white", timeControl: "blitz_3_2" }); client.on("matchFound", (m) => { const bot = m.players.find((p) => p.isBot); enterGame(m.matchId, { vsBot: true, botLevel: bot?.botLevel }); }); // Always unrated. No match_found CALLBACK fires for direct play_bot; // the game_result at the end fires as usual (no rating block).Stockfish lv6Black ♚LIVE♜♞♝♛♚♝♞♜♟♟♟♟♟♟♟♟♟♟♟♟♟♟♟♟♜♞♝♛♚♝♞♜UserWhite ♔to move// vs Stockfish — playBot resolves instantly, no gateUser · WhiteStockfish lv6 · BlackAclient.move("e2e4");Bclient.move("c7c5");Aclient.move("g1f3");Bclient.move("d7d6");Aclient.move("d2d4");Bclient.move("c5d4");Aclient.move("f3d4");Bclient.move("g8f6");▶ looping · pure HTML/CSS/JS · A = white seat, B = black seatIf it goes wrong Handling error: bots_unavailable No Stockfish worker right now — disable bot play, offer matchmaking. error: already_in_game Finish or resign the active game first; subscribe() re-attaches to it. - 4
Nobody to pair with — the autoBot branch
ts// Default (autoBot: true): still unpaired after ~30s → the engine starts an // UNRATED bot game — matchFound arrives with an isBot seat; tell the user: client.on("matchFound", (m) => { if (m.players.some((p) => p.isBot)) toast("No opponent found — warming up vs the computer"); enterGame(m.matchId); }); // Opt-out (autoBot: false): keep waiting for a human until the queue TTL (~90s): client.queue({ variant: "standard", timeControl: "blitz_3_2", rated: true, autoBot: false }); // After the TTL the entry expires server-side; offer a re-queue. // (After a no-show requeue, autoBot resets to ON — the preference is per-enqueue.) - 5
Connection drops mid-game
- 📵network drops mid-game
- ⏱️clock keeps running (server-authoritative)
- 🔁auto-reconnect, same token
- 📥finds the game by userId → pushes game_state
- 🎨re-render everything from the snapshot
tsclient.on("reconnecting", ({ attempt }) => showBanner(`reconnecting… (${attempt})`)); client.on("open", () => { hideBanner(); // Own game: restored automatically — game_state arrives by itself, carrying // the match identity (variant, timeControl, players), so the opponent panel // needs nothing remembered from matchFound. // NOT restored — re-request: if (watching) client.subscribe(watchedRoom); if (wasQueued) client.queue(lastQueueOpts); }); client.on("gameState", (s) => renderEverything(s)); // snapshot = full truthIf it goes wrong Handling Close code 4001 mid-game Token expired. authExpired fires; re-mint via login flow, reconnect — the game is still there (found by userId). Reconnect gave up (maxReconnectAttempts) close fires with willReconnect:false — show a manual “reconnect” button that calls client.connect(). Game ended while offline The new game_state has status "finished" — show the result screen; the platform got game_result regardless. - 6
Second tab / second device
ts// The user opens the game in a second tab. The NEW socket wins; the old one // closes with 4002 and must not retry: client.on("replaced", () => showScreen("This game is open in another tab/window")); // The new tab's socket re-attached to the game automatically (by userId) — // nothing else to do. Never auto-reconnect on 4002, or the tabs fight forever. - 7
The bulletproof webhook receiver
ts// The bulletproof receiver — dedupe, ACK fast, process async: app.post("/callbacks", async (req, res) => { const ev = req.body; const fresh = await db.tryInsert("callback_events", { id: ev.eventId }); // unique key if (!fresh) return res.sendStatus(409); // duplicate → engine stops retrying await jobs.enqueue(ev); // heavy work OFF the request path res.sendStatus(200); }); // The job worker: // game_result → apply ratings (clamp floor) → release busy view // match_aborted → clear "in game" UI state // tournament_complete → save standings // anything unknown → log + ignore (it was already ACKed — forward compatible) // The safety net (cron, every few minutes): // GET /v1/matches/results?since=<cursor> → same handler, same dedupeScenario Why it's covered Same event twice Expected (at-least-once). The unique-key insert makes the second a 409 — handled. Events out of order Expected. Never assume match_found precedes game_result — key your handling on matchId + type. Webhook was down an hour game_result / tournament_complete kept retrying; the rest may have dead-lettered after 20 tries. The reconciliation job recovers results regardless. Handler crashed after ACK Your job queue retries it — that's why processing is async and idempotent internally. - 8
Run an arena tournament
- 🏗️POST /v1/tournaments (arena, 60min)
- ➕join per entrant (rating in body)
- ▶️POST /start
- 🔁pair → play → game_result → re-pair until time up
- 📨tournament_complete → save standings
tsawait engine.post("/v1/tournaments", { format: "arena", variant: "standard", timeControl: "blitz_3_2", rated: true, arena: { durationMin: 60 }, }); for (const u of entrants) { await engine.post(`/v1/tournaments/${id}/join`, { userId: u.id, rating: u.blitzRating }); } await engine.post(`/v1/tournaments/${id}/start`); // Live standings for your lobby: poll GET /v1/tournaments/{id}. // Each pairing pushes match_found to connected entrants' sockets and fires the // callback — use the callback to notify entrants who are offline.If it goes wrong Handling /join → 409 User already in an active tournament — one at a time. Entrant queues matchmaking mid-tournament Rejected (already_in_game) unless allowMatchmakingDuringTournament:true at create. Entrant offline at round start grace_period holds the room; match_found callback is your notify hook. Never arrives → forfeits that game only. - 9
Spectate a live game
tsclient.subscribe("match_abc"); // → game_state (you: "spectator") + moves // The snapshot's players/variant/timeControl identify who is playing — each seat // carries username, and a spectator never receives a match_found, so this is where // the names come from. client.on("gameState", (s) => { if (s.you === "spectator") renderReadOnly(s); }); client.unsubscribe("match_abc"); // stop watching // Tournament games broadcast on a delay (spectatorDelaySec); moves from // spectators are rejected; subscriptions are NOT restored after reconnect.hikaru_fanBlack ♚LIVE♜♞♝♛♚♝♞♜♟♟♟♟♟♟♟♟♟♟♟♟♟♟♟♟♜♞♝♛♚♝♞♜magnus_fanWhite ♔to moveIf it goes wrong Handling error: spectator_limit Per-game cap reached — show “stream full”. error: not_found Game over or bad id — refresh your live-games list (GET /v1/matches/{id} for the result). - 10
Show a game review
tsconst r = await engine.get(`/v1/matches/${matchId}/review`); if (r.responseCode === 202) schedulePoll(); // generating — poll until 200 else renderReview(r.data); // accuracy, estRating, 10 classes, evalGraph // Generated once by Stockfish, then permanent. 503 → analysis engine down, retry later.