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

    Rated 1v1 — the core loop

    Finding a game · Playing a game · Callbacks

    1. 👆Play · blitz 3|2
    2. 📤queue(rated:true)
    3. 🔎pulls your /users/{userId} → pool → pair
    4. 📥matchFound → route to board
    5. ✅both click Ready →accept()
    6. 🔁move loop
    7. 📨game_result → apply rating
    ts
    client.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 seat
    If it goes wrongHandling
    queue rejected: already_in_gameRe-attach instead: client.subscribe() with no room — their active game's state is pushed back.
    queue rejected: platform_unavailableYour /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 seenWebhook down? The engine retries forever, and the reconciliation job picks it up. Nothing to do client-side.
  2. 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. 3

    Play against the computer

    Bot games

    1. 🤖vs computer · level 6
    2. 📤playBot({ level: 6 })
    3. 🏠room resolves immediately — no queue, no gate
    4. 📥matchFound (isBot seat) + gameState → play
    ts
    client.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 seat
    If it goes wrongHandling
    error: bots_unavailableNo Stockfish worker right now — disable bot play, offer matchmaking.
    error: already_in_gameFinish or resign the active game first; subscribe() re-attaches to it.
  4. 4

    Nobody to pair with — the autoBot branch

    Queue options

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

    Connection drops mid-game

    Sessions & connection

    1. 📵network drops mid-game
    2. ⏱️clock keeps running (server-authoritative)
    3. 🔁auto-reconnect, same token
    4. 📥finds the game by userId → pushes game_state
    5. 🎨re-render everything from the snapshot
    ts
    client.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 truth
    If it goes wrongHandling
    Close code 4001 mid-gameToken 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 offlineThe new game_state has status "finished" — show the result screen; the platform got game_result regardless.
  6. 6

    Second tab / second device

    One socket per user

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

    The bulletproof webhook receiver

    Callbacks & reliability

    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 dedupe
    ScenarioWhy it's covered
    Same event twiceExpected (at-least-once). The unique-key insert makes the second a 409 — handled.
    Events out of orderExpected. Never assume match_found precedes game_result — key your handling on matchId + type.
    Webhook was down an hourgame_result / tournament_complete kept retrying; the rest may have dead-lettered after 20 tries. The reconciliation job recovers results regardless.
    Handler crashed after ACKYour job queue retries it — that's why processing is async and idempotent internally.
  8. 8

    Run an arena tournament

    Tournaments

    1. 🏗️POST /v1/tournaments (arena, 60min)
    2. ➕join per entrant (rating in body)
    3. ▶️POST /start
    4. 🔁pair → play → game_result → re-pair until time up
    5. 📨tournament_complete → save standings
    ts
    await 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 wrongHandling
    /join → 409User already in an active tournament — one at a time.
    Entrant queues matchmaking mid-tournamentRejected (already_in_game) unless allowMatchmakingDuringTournament:true at create.
    Entrant offline at round startgrace_period holds the room; match_found callback is your notify hook. Never arrives → forfeits that game only.
  9. 9

    Spectate a live game

    Spectating

    ts
    client.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 move
    If it goes wrongHandling
    error: spectator_limitPer-game cap reached — show “stream full”.
    error: not_foundGame over or bad id — refresh your live-games list (GET /v1/matches/{id} for the result).
  10. 10

    Show a game review

    After the game

    ts
    const 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.