GameSDK multiplayer & ranked reference
This page is the reference for GameSDK.listRooms(), GameSDK.connectRoom(), the room handle and its events, and every GameSDK.ranked method. For concepts and design guidance, see Real-time multiplayer and Ranked matchmaking. For the rest of the SDK, see GameSDK core reference.
At a glance
Member | Returns | Sign-in | Needs realtime enabled |
|---|---|---|---|
| No | Yes | |
| No | Yes | |
| Yes | No | |
| Yes | No | |
| Yes | No | |
| Yes | No | |
| Yes | No | |
| Yes | No | |
| Yes | No | |
| No | No |
Multiplayer
Enabling
Rooms only work for game versions published with realtime enabled (see Packaging & publishing). For a version without realtime, the browser's Content Security Policy blocks the connection:
listRooms()resolvesnull.connectRoom()still returns a room handle, which then fireserrorwith{ code: "ws_error" }, followed byclose.
connectRoom() throws Error("realtime not enabled for this game") only if the SDK was loaded without a relay address. Every game on the site loads the same SDK build, which has the address compiled in, so this can only happen if you run the bundle outside the site.
Rooms are scoped to your game (not a version). The SDK works out your game from the page URL, so call these methods from the game document the platform serves.
GameSDK.listRooms()
Lists your game's currently open rooms, busiest first.
GameSDK.listRooms(): Promise<RoomList | null>Parameters: none.
Resolves:
type RoomList = {
gameId: string; // your game id
rooms: { room: string; peers: number }[]; // busiest first, at most 200
maxPeersPerRoom: number; // currently 64
};Resolves null when realtime isn't enabled for the version, when the relay can't be reached, or when it returns an error. It never rejects.
Sign-in: not required. Guests: same behaviour as signed-in players.
Notes
peerscounts every open connection in the room right now, including spectators and reconnecting players.The list is public: anyone can see every ordinary room your game has open, including rooms named with a friends-only code. Private match rooms (names starting with
match-) are never listed.There's no filtering or paging. Filter by your own room-name convention.
const list = await GameSDK.listRooms();
if (!list) return showOffline();
const open = list.rooms.find((r) => r.room.startsWith("pub-") && r.peers < list.maxPeersPerRoom);GameSDK.connectRoom(opts)
Opens a WebSocket to the relay and joins a room.
GameSDK.connectRoom(opts?: { room?: string; name?: string; ticket?: string }): RoomParam | Type | Default | Description |
|---|---|---|---|
|
|
| The room name. Must match |
|
|
| The display name other peers see. The server shortens it to 32 characters. It isn't verified. |
|
| none | Required for a private match room: pass |
Returns a Room right away. The connection opens asynchronously, and the joined event marks success.
Failure: join problems arrive as an error event (with a code) followed by close. See Error codes.
Sign-in: not required for ordinary rooms. Guests: can connect. Nothing about the account is sent to the relay. A private match room needs a ticket, which only a signed-in ranked player gets.
Keep-alive: connectRoom() starts an automatic keep-alive. The SDK sends { t: "ping" } to the relay every 45 seconds while the socket is open; the relay answers pong and the SDK swallows it, so no event reaches your game. The interval is cleared on close and by room.close().
This exists because an idle connection is closed by the edge at about 125 seconds (measured 125.1 / 125.2 / 125.1 s) with close code 1006 and no close frame. With the keep-alive running, a connection that sends nothing else survived a 20-minute idle test in Chromium, Firefox and WebKit. You don't need to send your own pings, and your own traffic would count against the 30-messages-per-second cap. A non-SDK client needs traffic at intervals of 60 seconds or less.
const room = GameSDK.connectRoom({ room: "pub-3", name: "Ana" });
room.on("joined", ({ peerId, peers, snapshots }) => startGame(peerId, peers, snapshots));Private match rooms
Every ranked match has a private room: match.room is match-<match id>, and match.roomTicket is the current player's ticket into it.
const room = GameSDK.connectRoom({ room: match.room, ticket: match.roomTicket, name: match.me.handle });Rule | Value |
|---|---|
Who can join | Only the match's two players, each with a ticket issued to them |
Ticket lifetime | 15 minutes. It's only checked when you connect. |
Fresh tickets | Every ranked response that includes the match ( |
Connections per player | 1. Connecting again closes the player's older connection. |
Players per room | 2 |
Listed by | Never |
Situation | Events on the affected handle | WebSocket close code |
|---|---|---|
No ticket, or an invalid or expired one, or one for another room or game |
| 4401 |
The same player connected again (the older handle) |
| 4409 |
Both arrive on close with the code and reconnectable: false. On ticket_required (4401), fetch the match with ranked.match(id) and connect again with the new roomTicket. On 4409, don't reconnect: the player has this match open somewhere else, and reconnecting would kick that connection, which would kick this one back.
Room handle
interface Room {
peerId: string | null; // yours; null until "joined"
peers: Record<string, { peerId: string; name: string }>; // other connected peers (not you)
on(event: RoomEvent, cb: (arg) => void): Room; // chainable
setState(data: any): void;
send(data: any): void;
close(): void;
}room.on(event, callback)
Registers a listener. You can register several listeners for the same event, and they run in the order you added them. An exception thrown in a listener is caught and ignored. There is no off(). Returns the room, so calls can be chained.
room.setState(data)
Broadcasts data to every other peer as a state event, and stores it as your snapshot for peers who join later.
Param | Type | Description |
|---|---|---|
| any JSON-serialisable value | Your latest state. |
Silently dropped unless the socket is open. Wait for
joined.Never echoed back to you.
The stored snapshot is replaced only if
datais at most 8 KB of JSON. A larger payload is still broadcast, but the previous snapshot is kept.Counts toward the 30 messages/second limit and the 8 KB frame limit.
room.send(data)
Broadcasts data to every other peer as a message event. It isn't stored.
Param | Type | Description |
|---|---|---|
| any JSON-serialisable value | A one-off event. |
It follows the same open-socket, no-echo, rate and size rules as setState.
room.close()
Closes the connection and stops the SDK's keep-alive. Everyone else gets peerLeft for your peerId, and your snapshot is discarded. Your own close event fires.
Room events
The SDK surfaces these events. Events not listed here aren't delivered.
joined
You're in the room. It fires once per connection.
{
peerId: string; // your id for this connection
peers: { peerId: string; name: string }[]; // everyone already here (not you)
snapshots: { peerId: string; data: any }[]; // their latest setState, if any
}room.peerId and room.peers are filled in before your listener runs.
peers is in join order, oldest first, and never includes you. That ordering is a guarantee you can build on — peers[0] is the longest-connected peer in the room, which is enough to pick a host deterministically. (room.peers is a keyed object and has no meaningful order; use this array when order matters.)
peerJoined
{ peerId: string; name: string }Another peer joined. room.peers is updated first.
peerLeft
{ peerId: string }A peer disconnected or called close(). Their snapshot is gone. It is removed from room.peers first.
state
{ peerId: string; data: any }Another peer called setState(data). peerId is set by the relay and can be trusted to identify the connection that sent it. data can't be trusted.
message
{ peerId: string; data: any }Another peer called send(data).
Ordering, per sender: at or below the 30-messages-per-second cap, delivery from any one peer is FIFO and lossless. Measured: 2,000 numbered messages sent at 24.4/s all arrived, in strictly increasing order, with no drops or duplicates. This holds for state as well — each peer has one socket and the relay fans out in read order.
Not guaranteed: ordering between different senders (they interleave freely), and anything sent over the cap, which is dropped (see rate_limited).
error
{ code: string }A problem reported by the relay, or ws_error for a connection-level failure. See Error codes. Some errors are followed by close; others (such as rate_limited) leave the connection open.
close
{ code: number; reason: string; reconnectable: boolean }The connection has ended, whether you closed it, the server refused or closed it, the network dropped it, or (in a private match room) the same player connected again elsewhere. The SDK doesn't reconnect. Call connectRoom() again, and you'll get a new peerId.
Field | Notes |
|---|---|
| The WebSocket close code. |
| The close reason string. Usually |
|
|
Branch on reconnectable, not on the raw code:
room.on("close", ({ code, reconnectable }) => {
if (reconnectable) return reconnectWithBackoff();
if (code === 4401) return refreshTicketAndReconnect();
showDisconnectedMessage(); // 4409 and everything else: stay closed
});Error codes
Code | Cause | Connection |
|---|---|---|
| The room already has 64 connections (2 in a private match room). | Closed |
| Your game already has 200 open rooms, and this join would create a new one. | Closed |
| The relay is at its overall room limit, or this IP address already has 20 open connections. | Closed |
| The room name (or the game id worked out from the page) isn't 1–64 characters of | Closed |
| A private match room ( | Closed (code 4401) |
| More than 30 messages in the last second from this connection. That message was dropped silently — no queue, no retry, and nothing tells the other peers it went missing. | Open |
| A frame was over 8 KB. That message was dropped. | Open (see note) |
| The relay couldn't parse the frame. You won't normally see this from the SDK. | Open |
| SDK-side WebSocket error (for example, blocked by CSP or a network failure). | Usually followed by |
too_large covers frames up to 1 KB over the limit. A frame larger than that (over 9 KB in total) is refused at the socket level and closes the connection without a too_large error, so check sizes yourself.
Relay limits
Limit | Value |
|---|---|
Connections per room | 64 (private match rooms: 2, one per player) |
Open rooms per game | 200 |
Open rooms across the relay | 10,000 |
Concurrent connections per IP address | 20 |
Frame size (whole | 8 KB ( |
Messages per connection per second ( | 30 |
Retained snapshot per peer | 8 KB of JSON, until that peer disconnects |
Rooms returned by | 200 |
Ranked
How ranked calls work
Every
GameSDK.ranked.*call is passed to the page, which makes the API request as the signed-in player and adds your game id automatically. Your game never handles the player's token or its own game id.Guests: every method except
leaderboard()resolves{ error: "sign_in_required" }, and the page may show the player a sign-in prompt. Embedded copies of your game play as guests (see Embedding & the score bridge). - Timeouts: if the page doesn't answer within 12 seconds, the promise resolvesnull.API failures resolve
{ error: "error" }. This covers an invalid preset orclockMs, not your turn, a move without the required hashes, match not found, not a participant, rate limited, and server errors. The specific reason isn't passed to the game.The promises never reject.
So every caller should handle three failure shapes:
const r = await GameSDK.ranked.queue("blitz", 180000);
if (r === null) { /* timeout / page unavailable */ }
else if (r.error === "sign_in_required") { /* prompt to sign in */ }
else if (r.error) { /* request failed */ }
else { /* success */ }Shared types
Match
type Match = {
id: string; // match id
gameId: string;
preset: string;
seat: "a" | "b"; // your seat; "a" moves first
status: "pending" | "complete" | "void";
result: "a" | "b" | "draw" | null; // winning seat, once complete
myOutcome: "win" | "loss" | "draw" | null; // from your side, once complete
me: { handle: string | null; ratingBefore: number | null; ratingAfter: number | null };
opponent: { handle: string | null; ratingBefore: number | null; ratingAfter: number | null };
room: string; // private realtime room: "match-" + id
roomTicket: string | null; // your ticket into `room` (15 min); null unless pending
opponentClaim: { // the opponent's report, while yours is outstanding
outcome: "win" | "draw"; // the opponent's own claimed result
respondBy: string; // ISO 8601; 90 s after their report
} | null;
moves: any[]; // authoritative move log, oldest first
toMove: "a" | "b";
myTurn: boolean; // status === "pending" && toMove === seat
voidReason: "disagreement" | "state_mismatch" | "abandoned" | "inactive" | null;
clockMs: number | null; // starting clock per player; null = untimed
myClockMs: number | null; // live remaining time (ticking if it's your turn)
oppClockMs: number | null;
};ratingBefore and ratingAfter are null until the match is complete.
opponentClaim is set only while the match is pending, the opponent has reported "win" or "draw", and you haven't reported. Answer it with report() before respondBy.
QueueResult
type QueueResult =
| { status: "waiting" }
| { status: "not_queued" } // poll() only: no live queue entry in this game
| { status: "matched"; match: Match };Presets
A preset is any string matching ^[a-z0-9][a-z0-9_-]{0,31}$. There's nothing to register. Each (game, preset) has its own queue and its own ratings.
ranked.queue(preset, clockMs?)
Joins matchmaking, or returns the player's unfinished match in this game.
GameSDK.ranked.queue(preset: string, clockMs?: number): Promise<QueueResult | { error: string } | null>Param | Type | Required | Description |
|---|---|---|---|
|
| Yes | Queue key (see Presets). |
|
| No | Each player's starting clock in ms. A positive integer, at most 86,400,000 (24 h). Leave it out for an untimed match. |
Behaviour
If the player already has an unfinished match in this game (any preset), it applies any timeout or deadline that's due. If the match is still in play, it resolves
{ status: "matched", match }for that match. A finished or void match is never returned.Otherwise it adds or refreshes the player's queue entry and tries to pair them. It checks up to 8 waiting players on the same game, preset and
clockMswhose entry was refreshed in the last 60 seconds, nearest rating first. It pairs with the first whose rating is withinmin(900, 100 + 12 × longerWaitSeconds), wherelongerWaitSecondsis the longer of the two players' waits. Seats are random. In a timed match, seat"a"'s clock starts when seat"a"first receives the match, or 30 seconds after pairing, whichever is first.Otherwise it resolves
{ status: "waiting" }.
A player is in at most one queue at a time, across all games. Queuing for a different game, preset or clock replaces the entry and restarts the wait. Queuing again for the same game, preset and clock while the entry is live keeps the original wait time. Players who asked for different clocks are never paired.
Limits: 90 calls per minute per player. Calls over the limit resolve { error: "error" }.
Sign-in: required.
const r = await GameSDK.ranked.queue("blitz", 3 * 60_000);ranked.poll()
Checks whether the player has been paired.
GameSDK.ranked.poll(): Promise<QueueResult | { error: string } | null>Parameters: none. It uses the current game.
Behaviour:
If the player has an unfinished match in this game, it first applies any timeout or deadline that's due. If the match is still in play after that, it resolves
{ status: "matched", match }.Otherwise, if the player has a queue entry for this game, it refreshes the entry and tries to pair the player (same rules as
queue(), using their original queued time). It resolves{ status: "matched", match }or{ status: "waiting" }.Otherwise it resolves
{ status: "not_queued" }: the player never queued in this game, canceled, was queued in another game, or their entry expired after 60 seconds without aqueue()orpoll(). Callqueue()again to keep searching.
Use it on startup to detect a match you can resume, and every 1–2 seconds while searching.
Limits: 20 polls per 10 seconds per player. Polling about twice a second is comfortably inside it.
Sign-in: required.
ranked.cancel()
Leaves the queue.
GameSDK.ranked.cancel(): Promise<{ ok: true } | { error: "sign_in_required" } | null>Parameters: none. It removes the player's queue entry, whichever game or preset it was for. It doesn't affect an unfinished match. It resolves { ok: true } even if the player wasn't queued, and even if the server returns an error. It resolves null if the request can't be sent at all, or after the 12-second timeout.
Sign-in: required.
ranked.move(matchId, mv, hashes?)
Submits a move to the authoritative log.
GameSDK.ranked.move(
matchId: string,
mv: any,
hashes?: { before?: string; after?: string }
): Promise<{ match: Match } | { error: string } | null>Param | Type | Required | Description |
|---|---|---|---|
|
| Yes |
|
| any JSON value | Yes | Your move. Stored as-is and never read by the platform. The request body is capped at 1 MB. |
|
| No | Your digest of the state you're moving from. |
|
| No | Your digest of the state after this move. It becomes the head of the chain. |
Behaviour, in order:
The caller must be a participant (otherwise
{ error: "error" }).Anything already due (a clock that ran out, an expired report deadline) is applied first. If the match isn't
pendingafter that, it resolves{ match }without recording the move.If it isn't the caller's turn, it resolves
{ error: "error" }.Hashes required: once any earlier move in the match sent
after, the move must include bothbeforeandafter. Otherwise it's rejected (API codehash_required) and resolves{ error: "error" }.Attestation: if the chain has a head and
beforediffers from it, the match voids (voidReason: "state_mismatch", no Elo change). It resolves{ match }. A private dispute mark goes against the diverging player (the opponent ifbeforeequals the hash from before their last move, otherwise the caller).Clock: if the match is timed and the caller's remaining time is used up, the match completes as a loss on time for the caller and the move isn't recorded. It resolves
{ match }.Otherwise it appends the move, sets the chain head to
after(if given), deducts the caller's thinking time, and passes the turn and the running clock to the opponent. It resolves{ match }.
Always check match.status in the response.
Sign-in: required.
const r = await GameSDK.ranked.move(match.id, { from: "e2", to: "e4" }, { before: h0, after: h1 });
if (r && r.match && r.match.status !== "pending") showFinal(r.match);ranked.report(matchId, outcome, resultHash)
Reports the finished game's outcome from the caller's side.
GameSDK.ranked.report(
matchId: string,
outcome: "win" | "loss" | "draw",
resultHash: string
): Promise<{ match: Match } | { error: string } | null>Param | Type | Required | Description |
|---|---|---|---|
|
| Yes |
|
|
| Yes | The result from your side. Any other value resolves |
|
| Yes | A digest both clients compute identically from the finished game (for example, a hash of the move list plus the winner). |
Behaviour:
Case | Effect |
|---|---|
The match isn't | No change. Resolves the match as it is. |
| Completes immediately. The caller loses (a concession), and Elo updates. |
| Stored. The match stays |
| Completes, and Elo updates. |
| Voids ( |
The opponent sees a one-sided "win" or "draw" as opponentClaim and has 90 seconds to answer it. If they don't:
Timed: a loss on time that had already happened when the report was made stands. Otherwise no flag ends the match during the 90 seconds (a move made after the mover's time is gone still loses on time). After them, a
"draw"report stands as a draw, and a"win"report stands only if the opponent is to move. A"win"reported by the player to move is ignored and the clock decides.Untimed: the match voids as
"abandoned"(no Elo change), and the silent player gets a private abandon mark.
These rules are applied in the background (about every 15 seconds) and whenever the match is read, moved on or reported. See Ranked matchmaking.
Sign-in: required.
ranked.match(matchId)
Fetches the current match state. Only participants can read it.
GameSDK.ranked.match(matchId: string): Promise<{ match: Match } | { error: string } | null>Param | Type | Required | Description |
|---|---|---|---|
|
| Yes |
|
Behaviour: before responding, it starts seat "a"'s first clock if the caller is seat "a" and it hasn't started yet, then applies anything that's due: a timeout or an expired one-sided report (timed matches), or abandonment or 24-hour inactivity (untimed matches). The response includes a fresh roomTicket. Use it as the source of truth for live play, clock sync, reconnection and final results.
Note that the result is wrapped: read r.match, not r.
Failure: { error: "error" } if the match doesn't exist or the caller isn't a participant.
Sign-in: required.
ranked.rating(preset)
Gets the signed-in player's rating for a preset in this game.
GameSDK.ranked.rating(preset: string): Promise<Rating | { error: string } | null>
type Rating = { rating: number; games: number; wins: number; losses: number; draws: number };Param | Type | Required | Description |
|---|---|---|---|
|
| Yes | Queue key. |
The first call creates a starting entry (rating: 1200, all counts 0). Only completed matches change the numbers.
Sign-in: required.
ranked.leaderboard(preset)
Gets the top players for a preset in this game.
GameSDK.ranked.leaderboard(preset: string): Promise<{ preset: string; leaderboard: LeaderboardRow[] } | { leaderboard: [] } | null>
type LeaderboardRow = {
rank: number; // 1-based
handle: string | null;
displayName: string | null;
rating: number;
games: number; wins: number; losses: number; draws: number;
};Param | Type | Required | Description |
|---|---|---|---|
|
| Yes | Queue key. |
The top 50 by rating (highest first). Only players with at least one completed match are included. Banned and deleted accounts are left out.
On an API error (including an invalid preset) it resolves
{ leaderboard: [] }.The result is an object, not an array: read
r.leaderboard.
Sign-in: not required. Guests can read it.
const r = await GameSDK.ranked.leaderboard("blitz");
const rows = (r && r.leaderboard) || [];Rating parameters
Parameter | Value |
|---|---|
Starting rating | 1200 |
K-factor, fewer than 30 games in the preset | 32 |
K-factor, 30 or more games | 16 |
Floor | 100 |
Update |
|
Ranked limits
Limit | Value |
|---|---|
| 90 per minute per player |
Queue candidates checked per | 8 nearest by rating |
Rating gap allowed |
|
Queue entry expiry | 60 seconds without a |
| Positive integer, at most 86,400,000 |
Seat | First time seat |
| 1–256 characters |
Window to answer a one-sided report | 90 seconds (then untimed matches void as |
Untimed inactivity limit | 24 hours without a move or report (voids as |
Background settlement | About every 15 seconds |
Match room ticket | Valid 15 minutes, checked at connect |
Leaderboard size | 50 |
SDK response timeout | 12 seconds (then it resolves |
The same operations exist as REST endpoints under /v1/ranked/* (queue, match, move, report, ratings, leaderboard) and are supported. Every per-player one needs a signed-in session — an API key gets 403 session_required — so they're for your own web front-end, not for server-to-server use. /v1/ranked/leaderboard is public. See REST API overview.