Quickstart

The smallest complete integration: one rated blitz game from login to persisted rating. Seven steps — two endpoints you host, one REST call, and the SDK. Each step links to the guide chapter that covers it in depth.

Trace legend: user what the player does · platform your server code · SDK @inachess/sdk on the client · engine engine-internal · callback engine → your webhook.

  1. 1

    Host two endpoints

    Prerequisite · Platform setup →

    1. 🌐host webhookPOST /callbacks
    2. 👤host user dataGET /users/{userId}
    3. 🔐share secret + IPs with the engine

    The engine calls you in exactly two places: a webhook that receives events, and a user-data endpoint it pulls ratings from when a player queues. Both are server-to-server, authenticated by IP whitelist + a shared X-Service-Auth secret.

    ts
    // 1a. The webhook — the engine POSTs events here and retries until you reply 2xx.
    app.post("/callbacks", (req, res) => {
      const ev = req.body;
      if (seen(ev.eventId)) return res.sendStatus(200);   // duplicate — already processed
      if (ev.type === "game_result") applyRatings(ev);    // persist ratingAfter per player
      markSeen(ev.eventId);
      res.sendStatus(200);
    });
    ts
    // 1b. The user-data endpoint — the engine pulls it (server-to-server) when a
    // player queues. Return EVERY rating category the player has; omit ones never played.
    // The payload sits under "data" (snake_case) — the engine unwraps that envelope.
    app.get("/users/:userId", (req, res) => {
      res.json({
        success: true,
        response_code: 200,
        message: "User found.",
        data: {
          user_id: req.params.userId,
          username: "magnus_fan",
          ratings: {
            "standard:blitz": { r: 1512.3, rd: 84.1, vol: 0.0598, lastGameAt: 1719828000000, floor: 1400 },
            "standard:rapid": { r: 1533.9, rd: 70.4, vol: 0.0591 },
          },
        },
        errors: null,
      });
    });
  2. 2

    Mint a session token at login

    Platform → Engine · POST /v1/sessions · Sessions →

    1. 👤User logs in
    2. 🔑platform callsPOST /v1/sessions
    3. ⚙️engine mints session token
    4. 📨hand token to that user's client

    One token per login, body carries userId only. Hand the token to that user's client — it is the client's socket credential for the whole session.

    http
    # 2. At login, mint one session token and hand it to that user's client:
    POST /v1/sessions
    X-Service-Auth: <secret>
    
    { "userId": "user_123" }
    
    → 201
    {
      "success": true,
      "response": "success",
      "responseCode": 201,
      "data": { "token": "s_9f3c...", "expiresAt": "2026-07-21 11:00:00.000" }
    }
  3. 3

    Connect the client

    Client → Engine · @inachess/sdk · SDK reference →

    1. 🔌SDK dials onceclient.connect()
    2. ✅token validated before the upgrade
    pnpm add @inachess/sdk
    ts
    // 3. In the client: one socket for the whole login session.
    import { EngineClient } from "@inachess/sdk";
    
    const client = new EngineClient({ url: "wss://engine.example.com/ws", token });
    
    client.on("matchFound", (m) => enterGame(m.matchId));            // route to the board
    client.on("gameState",  (s) => render(s));                       // full snapshot
    client.on("move",       (m) => render(m.fen, m.turn));           // per-move update
    client.on("gameOver",   (g) => show(g.result, g.reason));
    client.on("error",      (e) => handle(e.code));                  // branch on code, not message
    
    client.connect();
  4. 4

    Queue and get seated

    Client → Engine (socket) · Finding a game →

    1. 👆User clicks Play · blitz 3|2
    2. 📤SDK sendsclient.queue({ … })
    3. 🔎engine pulls your /users/{userId} → enters the pool
    1. 📡pushes match_found + game_state to both sockets
    2. ✅User clicks Ready
    3. 📤SDK sendsclient.accept()
    4. ⏱️clock starts once both accept
    ts
    // 4. Queue for a rated blitz game — never send a rating; the engine pulls it
    // from YOUR /users/{userId} endpoint (step 1b):
    client.queue({ variant: "standard", timeControl: "blitz_3_2", rated: true });
    
    // When paired, the engine pushes match_found (route to the board) followed by
    // game_state. The clock is NOT running yet — the ready_check gate holds it:
    client.on("gameState", (s) => {
      render(s);
      if (!s.started && s.gate === "ready_check" && !s.youAccepted) {
        showReadyButton();          // or client.accept() immediately on arrival
      }
    });
    client.accept();                // clock starts once BOTH players have accepted

    ⚠ The clock is held by the ready_check presence gate until both players send accept. Not confirming within ~30s aborts the match as a no_show — nobody's rating changes.

  5. 5

    Play

    Client ⇄ Engine (socket) · Playing a game →

    1. ♟️User drags e2 → e4
    2. 📤SDK sendsclient.move("e2e4")
    3. ✔️validates + ticks clock + broadcasts
    4. 📥SDK fireson("move") → render
    ts
    // 5. Play. Send UCI intent; render only what the engine broadcasts back:
    client.move("e2e4");            // promotion: 5-char UCI, e.g. "e7e8q"
    
    client.on("move", (m) => {
      render(m.fen, m.turn);        // m.whiteMs / m.blackMs — server clocks, never tick your own
    });
    client.on("gameOver", (g) => show(g.result, g.reason));   // ends this room only

    Live: each client.move(…) below slides a piece — the client only sends the UCI string, the board is server state pushed back:

    User BBlack ♚
    ♜
    ♞
    ♝
    ♛
    ♚
    ♝
    ♞
    ♜
    ♟
    ♟
    ♟
    ♟
    ♟
    ♟
    ♟
    ♟
    ♟
    ♟
    ♟
    ♟
    ♟
    ♟
    ♟
    ♟
    ♜
    ♞
    ♝
    ♛
    ♚
    ♝
    ♞
    ♜
    LIVE
    User AWhite ♔to move
    // the per-move loopUser 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
  6. 6

    Receive the result, persist the rating

    Engine → Platform (callback) · Callbacks →

    1. 🏁game ends → computes Glicko-2
    2. 📨engine POSTsgame_result
    3. 💾persist rating (clamp floor), reply 2xx
    json
    // 6. The result arrives at your webhook (step 1a) — it names its own rating
    // category, so you file it straight from the payload:
    {
      "eventId": "evt_003",
      "type": "game_result",
      "matchId": "match_abc",
      "variant": "standard", "timeControl": "blitz_3_2", "speed": "blitz",
      "result": "white_win", "termination": "checkmate",
      "players": [
        { "userId": "user_123", "username": "magnus_fan", "color": "white", "score": 1,
          "ratingAfter": { "r": 1524.7, "rd": 78.2, "vol": 0.0597 }, "delta": 12.4 },
        { "userId": "user_456", "username": "the_turk", "color": "black", "score": 0,
          "ratingAfter": { "r": 1589.1, "rd": 58.4, "vol": 0.0601 }, "delta": -10.9 }
      ]
    }
    // → ratings["standard:blitz"] = ratingAfter, clamped to your tracked floor.
  7. 7

    Run the reconciliation backstop

    Platform → Engine · GET /v1/matches/results · Reliability →

    http
    # 7. Backstop (periodic job): pull anything your webhook missed. Idempotent —
    # dedupe by the same eventId, persist nextCursor between runs.
    GET /v1/matches/results?since=<cursor>&limit=200
    X-Service-Auth: <secret>

You are integrated when…

  • Webhook replies 2xx and dedupes by eventId (send the same event twice — the rating must apply once).
  • GET /users/{userId} returns every rating category, keyed "<variant>:<speed>".
  • Login mints a session token and the client connects with it — one socket, no token in any URL.
  • queue → match_found → accept → moves → game_over works end to end.
  • game_result persists the rating into the category the payload names.
  • The reconciliation job runs on a schedule and applies missed results.

From here the guide goes deeper chapter by chapter — starting with Platform setup, the contract behind step 1.