Ranked matchmaking
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
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()orpoll()for 60 seconds expires, andpoll()then resolves{ status: "not_queued" }. Callqueue()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 aqueue(),poll()ormatch()response), or 30 seconds after pairing, whichever comes first. Keep polling while searching and start the game as soon asmatchedarrives.
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():
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.
It rejects the move if it isn't your turn, which fails the call.
It checks the state-hash chain. Once the match uses hashes, a move without both hashes fails the call.
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
matchhasstatus: "complete".It adds
moveto 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 nextmatch()call as the final word.Polling (authoritative): call
match(id)every second or two.match.movesis 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 | 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 | 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 |
|---|---|
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 ( | 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()orreport(). You don't need to keep polling just to make a match finish, but pollingmatch()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()andpoll()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") }clockMsmust 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:
afterbecomes 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
beforematches 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
beforeandafteron every move. Once any move in a match has sent anafterhash, every later move must send both hashes, or it's rejected (the API error ishash_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 anafterhash 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:
When your game starts (or after a reload), call
GameSDK.ranked.poll(). If it returnsmatchedwithmatch.status === "pending", resume that match.Replay
match.movesin order to rebuild the position. Usematch.seat,match.toMoveandmatch.myTurnto restore whose turn it is.Sync your clock display from
match.myClockMsandmatch.oppClockMs. These are live values: the clock of the player to move is already counting down.Reconnect the room with
connectRoom({ room: match.room, ticket: match.roomTicket }), using the ticket from the match you just fetched. YourpeerIdwill be new, and the opponent seespeerLeftfor your old connection (if it was still open) and thenpeerJoined.If
opponentClaimis 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 |
|
New rating |
|
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/ratingAfterandmatch.opponent.ratingBefore/ratingAftershow 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 |
Changing the result after the fact | Both |
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 |
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
Real-time multiplayer: the relay, rooms, and the non-authoritative trust model. - GameSDK multiplayer & ranked reference: every method with exact response shapes. - Tournaments: timed competitions with prize pools. - Scores, leaderboards & anti-cheat: verified single-player scores. - Errors & limits: rate limits and platform-wide caps.