API Docs

GameSDK multiplayer & ranked reference

AdminUpdated Sep 22, 2026

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

listRooms()

Promise<RoomList | null>

No

Yes

connectRoom(opts)

Room (synchronously)

No

Yes

ranked.queue(preset, clockMs?)

Promise<QueueResult>

Yes

No

ranked.poll()

Promise<QueueResult>

Yes

No

ranked.cancel()

Promise<{ ok: true }>

Yes

No

ranked.move(matchId, mv, hashes?)

Promise<{ match }>

Yes

No

ranked.report(matchId, outcome, resultHash)

Promise<{ match }>

Yes

No

ranked.match(matchId)

Promise<{ match }>

Yes

No

ranked.rating(preset)

Promise<Rating>

Yes

No

ranked.leaderboard(preset)

Promise<{ preset, leaderboard }>

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() resolves null.

  • connectRoom() still returns a room handle, which then fires error with { code: "ws_error" }, followed by close.

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

  • peers counts 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 }): Room

Param

Type

Default

Description

opts.room

string

"default"

The room name. Must match ^[A-Za-z0-9_-]{1,64}$. Anything else gets error bad_room, then close. The room is created if it doesn't exist. Names starting with match- are private match rooms.

opts.name

string

"player"

The display name other peers see. The server shortens it to 32 characters. It isn't verified.

opts.ticket

string

none

Required for a private match room: pass match.roomTicket from a ranked match. Ignored for ordinary rooms.

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 (queue, poll, match, move, report). null once the match isn't pending.

Connections per player

1. Connecting again closes the player's older connection.

Players per room

2

Listed by listRooms()

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

error { code: "ticket_required" }, then close

4401

The same player connected again (the older handle)

close only

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

data

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 data is 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

data

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

room_full

The room already has 64 connections (2 in a private match room).

Closed

game_room_limit

Your game already has 200 open rooms, and this join would create a new one.

Closed

server_full

The relay is at its overall room limit, or this IP address already has 20 open connections.

Closed

bad_room

The room name (or the game id worked out from the page) isn't 1–64 characters of A–Z a–z 0–9 _ -.

Closed

ticket_required

A private match room (match-…) without a valid ticket for this player, room and game.

Closed (code 4401)

rate_limited

More than 30 messages in the last second from this connection. That message was dropped.

Open

too_large

A frame was over 8 KB. That message was dropped.

Open (see note)

bad_message

The relay couldn't parse the frame. You won't normally see this from the SDK.

Open

ws_error

SDK-side WebSocket error (for example, blocked by CSP or a network failure).

Usually followed by close

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 {"t":…,"data":…} envelope)

8 KB (too_large, connection stays open); over 9 KB closes the connection

Messages per connection per second (setState + send together)

30

Retained snapshot per peer

8 KB of JSON, until that peer disconnects

Rooms returned by listRooms()

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 resolves null.

  • API failures resolve { error: "error" }. This covers an invalid preset or clockMs, 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

preset

string

Yes

Queue key (see Presets).

clockMs

number

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

  1. 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.

  2. 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 clockMs whose entry was refreshed in the last 60 seconds, nearest rating first. It pairs with the first whose rating is within min(900, 100 + 12 × longerWaitSeconds), where longerWaitSeconds is 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.

  3. 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 a queue() or poll(). Call queue() 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

matchId

string

Yes

match.id.

mv

any JSON value

Yes

Your move. Stored as-is and never read by the platform. The request body is capped at 1 MB.

hashes.before

string (1–256 characters)

No

Your digest of the state you're moving from.

hashes.after

string (1–256 characters)

No

Your digest of the state after this move. It becomes the head of the chain.

Behaviour, in order:

  1. The caller must be a participant (otherwise { error: "error" }).

  2. Anything already due (a clock that ran out, an expired report deadline) is applied first. If the match isn't pending after that, it resolves { match } without recording the move.

  3. If it isn't the caller's turn, it resolves { error: "error" }.

  4. Hashes required: once any earlier move in the match sent after, the move must include both before and after. Otherwise it's rejected (API code hash_required) and resolves { error: "error" }.

  5. Attestation: if the chain has a head and before differs 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 if before equals the hash from before their last move, otherwise the caller).

  6. 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 }.

  7. 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

matchId

string

Yes

match.id.

outcome

"win" | "loss" | "draw"

Yes

The result from your side. Any other value resolves { error: "error" }.

resultHash

string (1–256 characters)

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 pending (after applying anything that's due)

No change. Resolves the match as it is.

outcome: "loss"

Completes immediately. The caller loses (a concession), and Elo updates.

"win" / "draw", opponent hasn't reported

Stored. The match stays pending.

"win" / "draw", opponent's report has the same winning seat and the same resultHash

Completes, and Elo updates.

"win" / "draw", opponent's report differs in either

Voids (voidReason: "disagreement"). No Elo change.

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

matchId

string

Yes

match.id.

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

preset

string

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

preset

string

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

round(R + K × (S − 1 / (1 + 10^((Ropp − R) / 400)))), with S = 1 / 0.5 / 0

Ranked limits

Limit

Value

queue() calls

90 per minute per player

Queue candidates checked per queue() or poll()

8 nearest by rating

Rating gap allowed

min(900, 100 + 12 × longer of the two players' seconds waiting)

Queue entry expiry

60 seconds without a queue() or poll()

clockMs

Positive integer, at most 86,400,000

Seat "a" clock start

First time seat "a" receives the match, at most 30 seconds after pairing

hashes.before / hashes.after / resultHash

1–256 characters

Window to answer a one-sided report

90 seconds (then untimed matches void as "abandoned")

Untimed inactivity limit

24 hours without a move or report (voids as "inactive")

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 null)

Related

Was this page helpful?