Finding a game
Everything between “user clicks Play” and “the clock starts”: the queue, the casual pool, bot games, the match_found push, and the presence gate that protects players from no-show opponents.
Queue for a match
- 👆User clicks Play · blitz 3|2
- 📤SDK sends
client.queue({ … }) - 🔎engine pulls rating → enters the pool
- 🔁sweep ~1.5s · pair + busy-check · create room
Queueing is a socket message, not a REST call. The engine validates the game type, confirms the user is not in a game, pulls their rating from your user-data endpoint, and enters them in the pool. A periodic sweep (~1.5s) pairs seekers whose rating windows overlap — the window widens the longer they wait.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
type | "queue" | yes | — | socket message — there is no REST queue endpoint |
variant | string | yes | — | one of the variants from GET /v1/game-types, e.g. "standard" |
timeControl | string | yes | — | a time-control id from GET /v1/game-types, e.g. "blitz_3_2" |
rated | boolean | — | true | true = rated pool (Glicko-2 applies) · false = casual FIFO pool, no rating change |
autoBot | boolean | — | true | true = still unpaired after ~30s → unrated bot game (match_found arrives with an isBot seat) · false = keep waiting for a human until the queue TTL (~90s) |
// Matchmaking is a socket message — there is no REST queue endpoint.
client.queue({ variant: "standard", timeControl: "blitz_3_2", rated: true });
// The client NEVER sends a rating. At enqueue the engine pulls user detail + all
// rating categories from the platform (GET <platform>/users/{userId}) and selects
// the one matching this pool; if the platform is unreachable the enqueue is
// rejected (error code platform_unavailable) — there is no client-supplied fallback.
// Casual / unrated — separate pool, FIFO pairing, no rating change, no rating pull:
client.queue({ variant: "standard", timeControl: "blitz_3_2", rated: false });
// Opt out of the ~30s bot fallback and wait for a human until the queue TTL (~90s):
client.queue({ variant: "standard", timeControl: "blitz_3_2", rated: true, autoBot: false });
// Leave the pool:
client.queueCancel();
// Disconnecting also leaves the queue immediately — the engine sees presence
// directly and holds no seat. After a network blip, re-enqueue on reconnect.match_found — you are seated
- 📡pushes match_found → then game_state, to both sockets
- 📥SDK fires
on("matchFound") → route to board - ✅User clicks Ready
- 📤SDK sends
client.accept() - ⏱️clock starts once both accept
match_found is pushed the moment the player is seated in a new game — a matchmaking pair, a bot fallback, or a tournament round — always before that room's first game_state. Route to the board on it; matchId is the room id.
// On pairing the engine pushes match_found — BEFORE that room's first game_state —
// so the client routes to the board on it. Seat ratings are match-time snapshots,
// display info only, never authoritative:
{
"type": "match_found",
"matchId": "match_abc", // the room id — route to the board
"variant": "standard",
"timeControl": "blitz_3_2",
"players": [
{ "userId": "user_123", "username": "alice", "color": "white", "rating": 1512 },
{ "userId": "user_456", "username": "bob", "color": "black", "rating": 1498 }
]
}
// The autoBot fallback pushes the same event — the isBot seat is how the client
// knows the opponent is a computer (a bot seat carries no username):
{
"type": "match_found",
"matchId": "match_def",
"variant": "standard",
"timeControl": "blitz_3_2",
"players": [
{ "userId": "user_123", "username": "alice", "color": "white" },
{ "userId": "stockfish", "color": "black", "isBot": true, "botLevel": 4 }
]
}
client.on("matchFound", (m) => {
const opp = m.players.find((p) => p.userId !== myUserId);
enterGame(m.matchId, { vsBot: !!opp?.isBot, oppName: opp?.username, oppRating: opp?.rating });
});
// Nothing here needs to be persisted: every game_state repeats the same
// identity (variant, timeControl, players) — a client that reconnects after a
// page reload renders the opponent from the snapshot alone.ℹ The platform webhook receives a match_found callback too — but it is bookkeeping only. The engine already pushed the game to both sockets; the match never waits on callback delivery. (Direct play_bot games fire no callback at all.)
The presence gate
A pairing is not yet a game — a gate holds the clock until both players are demonstrably at the board, so nobody loses time (or rating) to an absent opponent:
| Gate | Used for | Behaviour |
|---|---|---|
ready_check | matchmaking (default) | Both players are already connected when the pairing is made. Each sends accept; the clock starts once both have. Not both by gateTimeoutSec (~30s) → no_show. |
grace_period | tournaments only | Entrants joined over REST and may be offline when their round opens, so the room is created and WAITS with the clock running from creation. An entrant who never arrives by the deadline forfeits. |
none | bot games | A bot seat is counted present automatically. The game starts as soon as the client subscribes — no accept needed. |
// The clock does NOT start at room creation — a presence gate holds it.
// game_state tells you everything about the gate:
{
"type": "game_state", "room": "match_abc",
"status": "active", // the game EXISTS — but…
"started": false, // …the clock is NOT running yet
"gate": "ready_check", // ready_check | grace_period | none
"youAccepted": false, // THIS recipient hasn't confirmed yet
"gateEndsAt": 1719828030000, // unix ms no-show deadline (only while !started)
"serverNow": 1719828000000,
/* …fen, turn, you, moves, whiteMs, blackMs as usual… */
}
client.accept(); // ready_check: confirm presence; every accept re-broadcasts
// state to both seats, so the waiting player sees "opponent ready"
// Until started flips to true, a move is refused with error code not_started.
// Not both accepted by gateEndsAt (~30s) → no-show:
// game_over { result: "aborted", reason: "no_show" } on the socket
// match_aborted callback to the platform — and the player who DID show up
// is requeued automatically.UX choice: a client that treats arriving at the board as confirmation may send accept on subscribe; one that wants an explicit “I'm ready” click can wait. Either way show the waiting state — the opponent may not have confirmed yet, and youAccepted / started tell you exactly which state you are in.
Bot games
- 🤖User picks “vs computer · level 6”
- 📤SDK sends
client.playBot({ … }) - 🏠room resolves immediately — no pairing wait, no gate
- 📥match_found (isBot seat) + game_state → play
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
type | "play_bot" | yes | — | socket message — no REST endpoint |
level | number | — | 4 | Stockfish skill 1..8; out-of-range values are clamped |
timeControl | string | yes | — | a time-control id from GET /v1/game-types, e.g. "blitz_3_2" |
color | "white" | "black" | — | "white" | the human's side; the bot takes the other |
// Playing a bot is a socket message — no REST endpoint, no queue, no gate.
client.playBot({ level: 6, color: "white", timeControl: "blitz_3_2" });
// Resolves immediately: match_found (its stockfish seat has isBot + botLevel)
// followed by game_state — the client routes to the board the same way as a
// human pair. The game is ALWAYS unrated, and no platform callback fires for
// starting it (the game_result at the end fires as usual, without ratings).
// level clamps to 1..8, color defaults to "white".
// No Stockfish available → error code bots_unavailable.
// Distinct from the autoBot fallback: a seeker unpaired after ~30s (autoBot on)
// is dropped to an unrated bot game and that one DOES emit the match_found
// callback, with a single player.What can go wrong
| Symptom | Cause & fix |
|---|---|
| error code already_in_game | The user has an active game (one active game per user — hard invariant), or is entered in an exclusive tournament. Route them back to their game: subscribe with no room re-attaches to it. |
| error code already_queued | Double enqueue. Treat as success or offer cancel — don't blind-retry. |
| error code rate_limited | Enqueue is rate-limited per session (each costs a platform call). Back off; don't loop queue/cancel. |
| error code platform_unavailable | The engine could not reach your /users/{userId} within ~2–3s. The enqueue is rejected by design — surface a retry to the user, fix endpoint availability. |
| game_over result aborted, reason no_show | The opponent (or this player) never confirmed the gate. No rating changes. The player who did accept is requeued automatically — show “finding a new opponent…”, not an error. |
| match_found came but the user closed the tab | On reconnect their own game is restored automatically (game_state pushed, match identity included — the lost match_found doesn't matter). If nobody confirms in time, the gate aborts it as a no_show. |
Full error-code list with handling advice: Errors & codes. Complete scenario walk-throughs (happy path + every branch): Use cases.