GameSDK multiplayer & ranked reference
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
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. That doesn't happen on coolgptgames.com.
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.
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 |
The close event carries no argument, so the close code isn't visible to your game. On ticket_required, fetch the match with ranked.match(id) and connect again with the new roomTicket.
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. 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.
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).
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
No argument. 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.
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. | 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: no dedicated rate limit.
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 |