Guides

Ranked matchmaking

AdminUpdated Sep 22, 2026

Ranked matchmaking

GameSDK.ranked adds skill-based 1v1 matchmaking with a persistent Elo rating to any head-to-head game. The platform handles the parts a client shouldn't be trusted with:

  • Pairing. It matches players of similar rating. - Turn order and clocks. It keeps the authoritative move log and each player's remaining time.

  • The result. Ratings only change when the outcome is conceded, agreed by both players, or decided by the clock. In a timed match, a report the opponent leaves unanswered can also stand, but only when it fits the game state.

Your game still owns the rules. Moves are opaque JSON to the platform, which never checks whether a move is legal. For the live back-and-forth you pair GameSDK.ranked with a real-time room (see Real-time multiplayer).

Exact signatures and response shapes are in GameSDK multiplayer & ranked reference.

Requirements

  • Players must be signed in. Every method except leaderboard() resolves { error: "sign_in_required" } for guests, and the page shows a sign-in prompt. leaderboard() works for everyone.

  • Embedded copies of your game play as guests. Ranked only works when the game is played on coolgptgames.com (see Embedding & the score bridge). - The match room needs realtime. Real-time rooms need realtime enabled on the version (see Packaging & publishing). You can technically play a ranked match without a room by polling match() for the opponent's moves (see Relaying moves), but a room makes it feel live.

Presets

A preset is a queue key your game picks, such as "blitz", "rapid", "classic" or "2v2_duel". There's nothing to register. The first time a preset string is used, that queue exists.

  • Format: lowercase letters, digits, _ and -. It starts with a letter or digit, with 1–32 characters in total (^[a-z0-9][a-z0-9_-]{0,31}$). An invalid preset fails the call.

  • Separate ratings. Each player has one rating per (your game, preset). A player's "blitz" rating is separate from their "rapid" rating, and neither affects any other game.

  • Separate queues. Players are only paired with others queued on the same game and the same preset.

Tie each preset to one clock setting. The clock isn't part of the preset's rating, but it is part of the queue: players are only paired when they asked for the same preset and the same clockMs (or both left it out). Two players on "blitz" with different clocks never meet, so mixing clocks inside one preset just splits your players into smaller queues. Always pass the same clockMs for a given preset. For example, "blitz" is always 3 minutes:

const PRESETS = {
  blitz: 3 * 60_000,
  rapid: 10 * 60_000,
  daily: undefined,   // untimed (a move or report is still needed at least every 24 h)
};
GameSDK.ranked.queue("blitz", PRESETS.blitz);

Match lifecycle

queue() ──► waiting ──poll()──► matched ──► play: move() … move()
                                               │
                   ┌───────────────────────────┴───────────────────────────┐
                   ▼                                                       ▼
        report("loss")  (concede)                    both report(win/draw) with the same resultHash
                   │                                                       │
                   ▼                                                       ▼
            complete (Elo moves)                               complete (Elo moves)
                                                                  or void on disagreement

   clocks:   a player runs out of time ──► complete, that player loses (Elo moves)
   timed:    only one player reported, 90 s pass ──► the report stands or the clock decides
   hashes:   the state-hash chain breaks ──► void (no Elo)
   untimed:  only one player reported, 90 s pass ──► void "abandoned" (no Elo)
   untimed:  no move or report for 24 h ──► void "inactive" (no Elo)

The platform applies timeouts, report deadlines and inactivity in the background (roughly every 15 seconds) as well as whenever a match is read, so a match settles even if both players have closed the game.

1. Queue

const res = await GameSDK.ranked.queue("blitz", 180_000);
if (res && res.error === "sign_in_required") return showSignIn();
if (res && res.status === "matched") return startMatch(res.match);
// res.status === "waiting"

queue(preset, clockMs?):

  • Resumes an existing match. If the player already has an unfinished match in this game, it returns that match straight away. This happens whichever preset you asked for.

  • Otherwise it tries to pair. It looks at up to 8 waiting players on the same game, preset and clock, closest rating first, and pairs with the first one inside the allowed rating gap (see How pairing works).

  • Otherwise the player waits. They are added to the queue.

A player can only be queued for one thing at a time. Queuing again for a different game, preset or clock replaces their previous queue entry. Queuing again for the same game, preset and clock while the entry is still live keeps the original queued time, so it never costs the player their place.

queue() is rate-limited to 90 calls per minute per player. Calls above that fail with { error: "error" }.

2. Wait for a pairing

const timer = setInterval(async () => {
  const r = await GameSDK.ranked.poll();
  if (!r || r.error) return;
  if (r.status === "matched") { clearInterval(timer); startMatch(r.match); }
  if (r.status === "not_queued") GameSDK.ranked.queue("blitz", 180_000);   // entry expired: queue again
}, 1500);

Each poll() does three things:

  • It returns the player's match if someone has paired with them.

  • Otherwise it tries to pair the player again, using their original queued time, so the allowed rating gap keeps widening while they wait.

  • It keeps the queue entry alive. An entry that isn't refreshed by queue() or poll() for 60 seconds expires, and poll() then resolves { status: "not_queued" }. Call queue() again if the player is still searching.

not_queued is also what you get if the player never queued in this game, canceled, or their entry was replaced by a queue in another game. Polling every 1–2 seconds is all a search screen needs. You don't have to call queue() again to widen the search.

To let players cancel:

cancelButton.onclick = async () => {
  clearInterval(timer);
  await GameSDK.ranked.cancel();
};

Call cancel() when the player leaves the search screen. If a player closes the tab while searching, their entry stops being refreshed and nobody is paired with it after 60 seconds, but until then they can still be paired. cancel() removes them straight away.

How pairing works

Every queue() and poll() call tries to pair the caller. Candidates are waiting players on the same game, preset and clock whose entry was refreshed in the last 60 seconds. Up to 8 are checked, closest rating first, and each is accepted if the rating gap is within the allowance:

allowed gap = min(900, 100 + 12 × seconds waited by whichever of the two has waited longer)

So the longer either player has waited, the wider the gap: about ±100 at first, ±460 after 30 seconds, and ±900 (the maximum) after 67 seconds.

Things to know:

  • Wait time counts from when the player first queued. Polling and re-queuing for the same preset and clock don't reset it, as long as the entry hasn't expired.

  • Seats are assigned at random. Seat "a" always moves first. In a timed match, seat "a"'s clock starts the first time that player receives the match (in a queue(), poll() or match() response), or 30 seconds after pairing, whichever comes first. Keep polling while searching and start the game as soon as matched arrives.

3. Start the match and connect the room

match looks like this (the full shape is in the reference):

{
  id: "0190…",          // match id
  preset: "blitz",
  seat: "a",            // "a" moves first
  status: "pending",    // "pending" | "complete" | "void"
  toMove: "a", myTurn: true,
  moves: [],            // authoritative move log (your opaque values, in order)
  clockMs: 180000, myClockMs: 180000, oppClockMs: 180000,   // null when untimed
  me:       { handle: "ana", ratingBefore: null, ratingAfter: null },
  opponent: { handle: "bo",  ratingBefore: null, ratingAfter: null },
  room: "match-0190…",  // private realtime room for this match
  roomTicket: "eyJn…",  // your ticket into that room (null once the match is over)
  opponentClaim: null,  // { outcome, respondBy } while the opponent's report awaits yours
  result: null, myOutcome: null, voidReason: null, gameId: "…"
}

Connect the match room to exchange moves live, passing your ticket:

const room = GameSDK.connectRoom({ room: match.room, ticket: match.roomTicket, name: match.me.handle });

The match room is private. It never appears in listRooms(), and the relay only admits the two players, each with their own ticket and one connection at a time. A ticket is valid for 15 minutes and only checked when you connect. Every ranked response that includes the match carries a fresh one, so before reconnecting, call GameSDK.ranked.match(id) and use the new roomTicket. A missing or expired ticket gets error ticket_required and then close. See Real-time multiplayer for the details.

Even in a private room, relayed messages come from your opponent's client, which may be modified. Only trust what you can check against the server's move log.

4. Play: submit every move to the server

const r = await GameSDK.ranked.move(match.id, myMove, { before: hash(stateBefore), after: hash(stateAfter) });
if (r && r.match) {
  match = r.match;
  room.send({ type: "move", move: myMove, n: match.moves.length });   // tell the opponent
  if (match.status !== "pending") finish(match);
}

What the server does on each move():

  1. It first applies anything that's already due (a clock that ran out, an expired report deadline). If that ends the match, you get the finished match back.

  2. It rejects the move if it isn't your turn, which fails the call.

  3. It checks the state-hash chain. Once the match uses hashes, a move without both hashes fails the call.

  4. Timed matches: it subtracts your thinking time from your clock. If you've already run out, the match ends and you lose on time. The move isn't recorded, and the resolved match has status: "complete".

  5. It adds move to the log, passes the turn to the opponent and starts their clock.

A move can be any JSON value. The server stores it as-is and never reads it. Requests are capped at 1 MB, but keep moves small because the whole log comes back in every response.

Timed matches have no increment or delay: each player has one budget for the whole game.

Relaying moves

The server doesn't push moves to the opponent. Your game has two ways to deliver them:

  • Room (fast): send the move over the match room after move() succeeds. The opponent applies it and checks it matches the log length, or treats the next match() call as the final word.

  • Polling (authoritative): call match(id) every second or two. match.moves is the full ordered log. This also covers the opponent disconnecting, missed messages, and page reloads.

Most games do both: they apply relayed moves right away and poll match() as a safety net and to catch the end of the game. Treat match.moves as the truth when the two disagree.

5. End the game and report

When your rules say the game is over, both clients call report():

const outcome = iWon ? "win" : iLost ? "loss" : "draw";
await GameSDK.ranked.report(match.id, outcome, resultHashOf(match, winnerSeat));

How the server resolves reports:

Situation

Result

A player reports "loss"

Finalized immediately: that player loses. A loss is a concession, and anyone can concede their own game. This is how resigning works.

A player reports "win" or "draw", and no report exists yet from the opponent

Stored. The opponent has 90 seconds to answer (see If the opponent doesn't report).

The match is already finished

Ignored. Returns the finished match.

A player can't simply claim a win. A "win" needs the opponent's matching report, a concession, the opponent's clock running out, or (in a timed match) an unanswered claim that fits the game state, as described below.

resultHash is a string (1–256 characters) that both clients must compute identically from the finished game. The platform only compares it and never reads it. A good choice is a hash of the complete move list plus the result:

function resultHashOf(match, winnerSeat /* "a" | "b" | "draw" */) {
  return djb2(JSON.stringify(match.moves) + "|" + winnerSeat);
}
function djb2(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; return h.toString(16); }

Because it covers the moves, two clients that saw different games (a desync or a forged move) will produce different hashes, and the match voids instead of awarding a rating.

Answering the opponent's report

While the opponent has reported and you haven't, your match object has opponentClaim set:

{ outcome: "win", respondBy: "2026-10-03T12:04:31.000Z" }   // outcome is the opponent's own result: "win" or "draw"

Answer before respondBy by calling report() with what your rules say:

  • The game really is over: report your own outcome with your resultHash. If the opponent claims "win", your "loss" confirms it (as a concession).

  • Your rules say the game isn't over, or ended differently: report your own view. Any report that differs from the claim, in the winner or the resultHash, voids the match with no rating change.

If the opponent doesn't report

Match type

What happens 90 seconds after a one-sided "win" or "draw"

Timed

If a clock had already run out when the report was made, that loss on time stands. Otherwise, for those 90 seconds a clock running out doesn't end the match by itself (a player who tries to move after their time is gone still loses on time). After that: a draw report stands as a draw. A win report stands only if the opponent is the one to move (the reporter made the last move). A win reported by the player who is to move is ignored, and the clock decides as if no report had been made: their clock kept running the whole time.

So an agreed draw that only one client manages to report still ends as a draw, and a player can't dodge a loss on time by filing a claim. Even so, make sure both clients always report.

6. Wait for the final result

After reporting, poll match() until status is no longer "pending":

const watch = setInterval(async () => {
  const r = await GameSDK.ranked.match(match.id);
  const m = r && r.match;
  if (!m || m.status === "pending") return;
  clearInterval(watch);
  if (m.status === "complete") {
    showResult(m.myOutcome, m.me.ratingBefore, m.me.ratingAfter);   // e.g. "win · 1200 → 1216"
  } else {
    showVoided(m.voidReason);   // "disagreement" | "state_mismatch" | "abandoned" | "inactive"
  }
}, 1500);

Reading a match also applies any timeout or deadline that's due, so match() always returns the up-to-date result. Matches are settled in the background too, so the result is there even if nobody was polling.

Clocks, timeouts and abandonment

Match type

How a missing or absent player is resolved

Timed (clockMs set)

The player whose turn it is has their clock running. When it reaches zero, they lose on time, and Elo moves. This is also how disconnects and rage-quits end: the absent player's clock runs out. Timed matches never void as "abandoned" or "inactive".

Important details:

  • Timeouts, report deadlines and inactivity are applied in the background (roughly every 15 seconds), and also straight away whenever the match is read or someone tries to move() or report(). You don't need to keep polling just to make a match finish, but polling match() is how your game finds out.

  • Seat "a"'s first clock starts when that player first receives the match, or 30 seconds after pairing, whichever comes first. Every later turn's clock starts when the previous move is recorded.

  • A one-sided "win" or "draw" in a timed match is resolved as described in If the opponent doesn't report.

  • queue() and poll() only ever hand back a match that is still in play. A finished, void or now-expired match is never returned, so a player isn't stuck with an old match when they queue again. An untimed match that's still in play comes back until someone finishes it or it voids after 24 hours without activity, so check for one when your game starts:

    const p = await GameSDK.ranked.poll();
    if (p && p.status === "matched") {
      resumeOrConcede(p.match);   // rebuild from p.match.moves, or report("loss")
    }
  • clockMs must be a positive whole number of milliseconds, at most 24 hours.

State-hash attestation (hashes)

move(matchId, mv, hashes) accepts an optional { before, after } pair. These are your own digests of the game state before and after this move. The platform never reads them. It only compares them, and they make divergence detectable:

  • after becomes the current "head" of the match's hash chain.

  • On the next move, the opponent sends their own before, which is the hash of the position they're moving from. If it doesn't match the head, the chain is broken.

  • On a break, the match voids (voidReason: "state_mismatch") with no Elo change, and the server records a private dispute mark against the player it blames:

    • if the mover's before matches the position before the opponent's last move, the opponent's move is blamed (the mover rejected an illegal or forged move);

    • otherwise the mover is blamed (they're the one out of sync).

Because moves alternate, every position is created by one player and confirmed by the other. A client that sends a move the opponent's rules won't accept can't keep the chain intact.

Using it well:

  • Hash a canonical state: the same bytes on both clients. Sort keys, and leave out UI-only fields and anything based on time.

  • Send both before and after on every move. Once any move in a match has sent an after hash, every later move must send both hashes, or it's rejected (the API error is hash_required; the SDK resolves { error: "error" }). So a client can't skip the check by leaving hashes out. A match where no move has sent an after hash doesn't use attestation at all.

  • Each hash is a string of 1–256 characters.

  • Attestation only catches disagreement between the two clients. It doesn't prove a move was legal if both clients accept it. Your rules code on each client is the judge of legality.

A reconnecting client can't read the stored hashes (they aren't returned). It rebuilds the position from match.moves and computes its own before for its next move.

Reconnection

The server's move log is enough to rebuild any match:

  1. When your game starts (or after a reload), call GameSDK.ranked.poll(). If it returns matched with match.status === "pending", resume that match.

  2. Replay match.moves in order to rebuild the position. Use match.seat, match.toMove and match.myTurn to restore whose turn it is.

  3. Sync your clock display from match.myClockMs and match.oppClockMs. These are live values: the clock of the player to move is already counting down.

  4. Reconnect the room with connectRoom({ room: match.room, ticket: match.roomTicket }), using the ticket from the match you just fetched. Your peerId will be new, and the opponent sees peerLeft for your old connection (if it was still open) and then peerJoined.

  5. If opponentClaim is set, answer it (see Answering the opponent's report).

A player has one connection to the match room at a time. If they open the match in a second tab, the first tab's room connection is closed.

In a timed match, the clock of a player who dropped keeps running while they're gone, and they lose on time if they don't come back.

Ratings

Parameter

Value

Starting rating

1200

K-factor: provisional (fewer than 30 rated games in this preset)

32

K-factor: established (30 or more)

16

Rating floor

100

Expected score

1 / (1 + 10^((opponent − mine) / 400))

New rating

round(mine + K × (score − expected)), where score is 1 / 0.5 / 0

  • Each player's K-factor depends on their own game count, so a new player's rating moves faster than an established opponent's in the same game.

  • Only completed matches count (games, wins, losses, draws). Void matches change nothing that's publicly visible.

  • rating(preset) returns the player's current numbers. Calling it creates a 1200 / 0-games entry if the player has none yet.

  • After a match, match.me.ratingBefore/ratingAfter and match.opponent.ratingBefore/ratingAfter show the change.

Leaderboard

GameSDK.ranked.leaderboard(preset) resolves { preset, leaderboard: [...] } with the top 50 players by rating for that preset. Only players with at least one completed match are included, and banned or deleted accounts are left out. Each row: { rank, handle, displayName, rating, games, wins, losses, draws }. Guests can read it.

Anti-cheat summary

Threat

What stops it

Claiming a win you didn't earn

A win needs the opponent's matching report, a concession, their clock running out, or (timed matches) an unanswered claim made after your own last move. The opponent sees opponentClaim and can dispute it.

Changing the result after the fact

Both resultHash values must match, or the match voids.

Illegal or forged moves

State-hash attestation voids the match and blames the diverging player. Both clients should also check legality.

Stalling or rage-quitting

Server-run clocks, applied in the background. In untimed matches, a one-sided report voids after 90 s and marks the absent player, and a match with no activity for 24 h voids.

Dodging a loss on time with a claim

A clock that already ran out stands, and a win claimed by the player to move is ignored.

Skipping state-hash checks

Once a match uses hashes, moves without them are rejected.

Moving out of turn

The server enforces turn order.

Outsiders in the match room

The room is private: only the two players' tickets get in.

Forged room messages from the opponent

Treat the server's match.moves as the truth. The room is only a fast hint.

A match that voids never changes anyone's rating, so the worst a cheater can do is spoil a game. They can't farm rating points.

Complete flow

const SDK = window.GameSDK;
const PRESET = "blitz", CLOCK = 180_000;
let match = null, room = null, pollTimer = null, refreshTimer = null, reported = false;

async function boot() {
  const p = await SDK.ranked.poll();                          // resume if mid-match
  if (p && p.status === "matched") return begin(p.match);
  showLobby();
}

async function findMatch() {
  const q = await SDK.ranked.queue(PRESET, CLOCK);
  if (!q) return showError("Network timeout");
  if (q.error === "sign_in_required") return showSignIn();
  if (q.error) return showError("Could not join the queue");
  if (q.status === "matched") return begin(q.match);
  pollTimer = setInterval(async () => {
    let r = await SDK.ranked.poll();                          // also retries pairing
    if (r && r.status === "not_queued") r = await SDK.ranked.queue(PRESET, CLOCK);   // entry expired
    if (r && r.status === "matched") begin(r.match);
  }, 1500);
}

async function stopSearching() { clearInterval(pollTimer); await SDK.ranked.cancel(); }

function begin(m) {
  clearInterval(pollTimer);
  if (match && match.id === m.id) return;                     // already playing it
  match = m; reported = false;
  rebuildFrom(match.moves);                                   // your rules engine
  connect();
  refreshTimer = setInterval(refresh, 1500);                  // authority + clocks + end detection
}

function connect() {
  const r = SDK.connectRoom({ room: match.room, ticket: match.roomTicket, name: match.me.handle });
  room = r;
  r.on("message", ({ data }) => {
    if (data && data.type === "move" && data.n === match.moves.length + 1) applyOpponentMove(data.move);
  });
  r.on("close", () => {
    if (room !== r || match.status !== "pending") return;
    setTimeout(async () => {                                  // fetch a fresh ticket, then reconnect
      if (await refresh() && room === r) connect();
    }, 3000);
  });
}

async function refresh() {
  const r = await SDK.ranked.match(match.id);
  if (!r || !r.match) return false;
  const m = r.match;
  if (m.moves.length > match.moves.length) rebuildFrom(m.moves);
  match = m;
  drawClocks(m.myClockMs, m.oppClockMs);
  if (m.status !== "pending") { finish(m); return false; }
  if (m.opponentClaim || gameIsOver()) await sendReport();    // answer before opponentClaim.respondBy
  return true;
}

async function playMove(mv) {
  const before = hashState(), after = hashState(applyLocally(mv));
  const r = await SDK.ranked.move(match.id, mv, { before, after });   // always send both
  if (!r || r.error) return undoLocal(mv);                     // not your turn, network error, …
  match = r.match;
  room.send({ type: "move", move: mv, n: match.moves.length });
  if (match.status !== "pending") return finish(match);
  if (gameIsOver()) await sendReport();
}

async function sendReport() {
  if (reported) return;
  reported = true;
  // Your rules decide. If they say the game isn't over, "win" disputes the opponent's claim.
  const w = gameIsOver() ? winnerSeat() : match.seat;         // "a" | "b" | "draw"
  const outcome = w === "draw" ? "draw" : w === match.seat ? "win" : "loss";
  const r = await SDK.ranked.report(match.id, outcome, resultHashOf(match, w));
  if (r && r.match) match = r.match; else reported = false;   // retry on the next refresh
}

async function resign() { await SDK.ranked.report(match.id, "loss", resultHashOf(match, match.seat === "a" ? "b" : "a")); }

function finish(m) {
  clearInterval(refreshTimer);
  if (room) room.close();
  showFinal(m);
}

Both clients run the same code. Whichever sees the game end first reports, and the other reports as soon as its refresh() rebuilds the final position or sees opponentClaim. refresh() keeps polling until the server settles the match.

Related

Was this page helpful?