Real-time multiplayer
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Real-time multiplayer
Cool GPT Games gives every game an optional real-time relay: a WebSocket service that passes small JSON payloads between the players in a room. You get rooms, a lobby browser and presence (who joined, who left) with two SDK calls: GameSDK.listRooms() and GameSDK.connectRoom(opts).
This guide explains how the relay works, what it does not do, and how to build a game on it that stays correct and hard to cheat. For exact signatures, see GameSDK multiplayer & ranked reference. For head-to-head games with a persistent skill rating, see Ranked matchmaking.
The relay is not authoritative. It forwards whatever each client sends and never runs your game logic. Read Design for a non-authoritative relay before you ship anything competitive.
Turning multiplayer on
Multiplayer is opt-in per game version. Publish the version with realtime enabled:
Publisher SDK:
realtime: truein the publish options.CLI:
--realtime, or"realtime": trueingame.json.REST:
realtime: truein the upload-init body.
See Packaging & publishing for details.
When realtime is enabled, the relay's address is added to your game's Content Security Policy so the browser lets your game connect. When it isn't, the browser blocks the connection: listRooms() resolves null, and a room from connectRoom() fires error (ws_error) and then close. Always handle both, so a build published without realtime fails cleanly.
Rooms belong to your game, not to a single version. Players on different versions of your game can end up in the same room, so keep your message format backward-compatible or put a version number in it (see Message design).
Core concepts
Concept | What it is |
|---|---|
Room | A named channel inside your game. It is created when the first player joins and deleted when the last one leaves. Nothing is provisioned in advance. |
Peer | One open connection. Each connection gets a random |
State ( | A player's latest snapshot. It goes to everyone else in the room, and the relay keeps the latest one per peer so players who join later receive it straight away. |
Message ( | A one-off event. It goes to everyone else in the room and isn't kept. |
The relay never sends your own setState or send payloads back to you. Apply your own changes locally.
Connecting to a room
const room = GameSDK.connectRoom({ room: "lobby-1", name: "Ana" });
room.on("joined", ({ peerId, peers, snapshots }) => {
// peerId – your id for this connection
// peers – everyone already in the room: [{ peerId, name }]
// snapshots – their latest setState payloads: [{ peerId, data }]
});
room.on("peerJoined", ({ peerId, name }) => { /* someone arrived */ });
room.on("peerLeft", ({ peerId }) => { /* someone left or dropped */ });
room.on("state", ({ peerId, data }) => { /* someone's setState */ });
room.on("message", ({ peerId, data }) => { /* someone's send */ });
room.on("error", ({ code }) => { /* see error codes below */ });
room.on("close", () => { /* the connection has ended */ });
room.setState({ x: 10, y: 20 }); // kept for late joiners
room.send({ type: "shoot" }); // not kept
room.close();Options:
Option | Default | Notes |
|---|---|---|
|
| The room name. Must match |
|
| A display name that other peers see. The server shortens it to 32 characters. It is not verified. See Player identity. |
| none | Only for private match rooms: the |
connectRoom() returns immediately. The socket opens in the background. setState() and send() quietly drop anything you call before the connection is open, or after it has closed. Wait for joined before you send anything.
The room handle also has two live properties:
room.peerId: your id. It isnulluntiljoinedfires.room.peers: an object of the other connected peers, keyed bypeerIdand shaped{ peerId, name }. It is updated as peers join and leave.
Rooms as lobbies
Room names are free-form, so you can use them for whatever grouping your game needs:
A single lobby: every player joins
"main", up to the room size limit.Numbered shards:
"lobby-1","lobby-2", … and you place players into them.Friends-only rooms: make up a random code (
"r-7fk2qa") and share it with friends. Anyone who knows or can see the name can join, so a room code is not access control (see Lobby browser).Match rooms: ranked matches come with their own private room (
match.room), which only the match's two players can join. See Private match rooms and Ranked matchmaking.
Don't start your own room names with match-. That prefix is reserved, and the relay refuses to let anyone into such a room without a ticket.
Lobby browser
GameSDK.listRooms() returns the rooms that are open right now for your game, with live player counts, busiest first:
const list = await GameSDK.listRooms();
// null → realtime isn't enabled for this game, or the relay can't be reached
// { gameId, rooms: [{ room: "lobby-1", peers: 61 }, ...], maxPeersPerRoom: 64 }It returns at most 200 rooms.
peersis the number of connections at the moment of the request. It can change before you join.The list is public and includes every ordinary room your game has open, friends-only codes too. Private match rooms (
match-…) are never listed. If a room shouldn't be joined, don't show it in your UI, and have your game ignore anyone who shouldn't be in it (see Player identity). A naming convention such as apub-prefix for rooms that are open to everyone makes filtering easy.
Matchmaking with rooms
There's no server-side matchmaking for casual rooms. You build it on listRooms(). A simple "fill the busiest room with space" approach:
async function quickJoin(maxPlayers = 8) {
const list = await GameSDK.listRooms();
if (!list) throw new Error("multiplayer unavailable");
const cap = Math.min(maxPlayers, list.maxPeersPerRoom);
const open = list.rooms.find((r) => r.room.startsWith("pub-") && r.peers < cap);
const name = open ? open.room : "pub-" + Math.random().toString(36).slice(2, 8);
return GameSDK.connectRoom({ room: name });
}Always handle a race. Someone else can take the last slot between your listRooms() and your join:
room.on("error", ({ code }) => {
if (code === "room_full" || code === "game_room_limit") quickJoinAgain();
});The server only enforces its own per-room cap (64). If your game has a smaller cap, such as 4 players, enforce it in your game logic: the host admits the first N peers and treats anyone else as a spectator. Two players who both find no open room will each create a new room, so expect that sometimes.
For skill-based 1v1 pairing with ratings, use Ranked matchmaking instead.
Player identity
A
peerIdis per connection. If a player reloads or reconnects, they get a newpeerId.nameis whatever the client sent. The relay doesn't check it against the player's account.Guests can use ordinary rooms. The relay doesn't require sign-in. The exception is a private match room, which needs a ticket that only a signed-in ranked player receives.
To show an account name, get it from GameSDK.getPlayer() (handle is null for guests) and pass it as name. Other players should treat it as a display label, not proof of who someone is. When identity matters (ranked play, rewards, scores), rely on server-side features that know who the signed-in player is: see Ranked matchmaking, Scores, leaderboards & anti-cheat and XP, achievements & social.
Reconnection
The SDK doesn't reconnect automatically, and a connection has no session to resume:
If the connection drops,
closefires. Everyone else getspeerLeftfor your oldpeerId, and the relay throws away your saved snapshot.To come back, call
connectRoom()again with the same room name. You get a newpeerId, and the others see apeerJoined.joined.snapshotscatches you up on everyone else's latest state.
A reconnect pattern with backoff:
function connectWithRetry(roomName, name, onRoom, attempt = 0) {
const room = GameSDK.connectRoom({ room: roomName, name });
let fatal = false;
room.on("joined", () => { attempt = 0; onRoom(room); });
room.on("error", ({ code }) => {
if (code === "bad_room" || code === "game_room_limit" || code === "ticket_required") fatal = true;
});
room.on("close", () => {
if (fatal || attempt >= 5) return showOfflineMessage();
const delay = Math.min(15000, 500 * 2 ** attempt);
setTimeout(() => connectWithRetry(roomName, name, onRoom, attempt + 1), delay);
});
}If your game has to recognise a returning player (to give them back their seat, for example), have the client send a stable token of its own in its first message or state, such as a random id kept in memory or in Saves & cloud progress, and have the host map it to the seat. Keep in mind that any client can claim any token.
The relay has no heartbeat that you can see. A connection that has quietly stalled might only be noticed when the browser or network finally closes it.
Private match rooms
Ranked matches get a private room so nobody but the two players can read or inject messages. The match object carries both pieces you need:
const room = GameSDK.connectRoom({ room: match.room, ticket: match.roomTicket, name: match.me.handle });Name.
match.roomismatch-<match id>. Every room name starting withmatch-is private.Ticket.
match.roomTicketis a signed ticket for this player, this game and this room. It's valid for 15 minutes and is only checked when you connect, so a connection that's already open isn't affected when it expires. Every ranked response that includes the match (queue(),poll(),match(),move(),report()) carries a fresh ticket. It'snullonce the match is over.No listing. Private rooms never appear in
listRooms().Two players at most, one connection each. If a player connects again (a reload, a second tab), their older connection is closed and the other player sees
peerLeftfor the oldpeerId, thenpeerJoinedfor the new one.
What the relay does when something is wrong:
Situation | What your game sees | WebSocket close code |
|---|---|---|
No ticket, an invalid or expired ticket, or a ticket for another room or game |
| 4401 |
The same player connected again elsewhere (the old connection) |
| 4409 |
The SDK's close event has no argument, so your game can't read the close code. Treat ticket_required as "fetch a fresh ticket": call GameSDK.ranked.match(id) and connect again with the new roomTicket. If close arrives without an error while the match is still pending, another tab or device may have taken over the seat, so don't reconnect in a tight loop. Ranked matchmaking has a complete reconnect example.
Limits (enforced by the server)
Limit | Value | What happens when exceeded |
|---|---|---|
Players (connections) per room | 64 (2 in a private match room) | Join refused: |
Open rooms per game | 200 | Creating another room is refused: |
Open rooms across the whole relay | 10,000 | Creating a room is refused: |
Concurrent connections per IP address | 20 |
|
Message size (the whole encoded frame) | 8 KB | That message is dropped and you get |
Messages per second, per connection | 30 (rolling 1-second window) | That message is dropped and you get |
Retained snapshot per peer | 8 KB of JSON | A larger |
Snapshot lifetime | Until you disconnect | Snapshots disappear when their peer leaves. |
Rooms returned by | 200 | Busiest first. |
The 8 KB size limit covers the whole frame the SDK sends, which is {"t":"state","data":…} or {"t":"msg","data":…}. Your payload needs to stay a little under 8 KB. A frame that's a little over the limit is dropped with a too_large error and the connection stays open. A frame more than 1 KB over the limit (above 9 KB in total) is refused by the socket layer and closes the connection, so check payload sizes on your side rather than relying on the error.
Other error codes you might see: bad_message (the relay couldn't parse what your client sent; the SDK always sends valid JSON, so you shouldn't see it in practice) and ws_error (a connection-level failure reported by the SDK). The full list is in GameSDK multiplayer & ranked reference.
Staying within 30 messages per second
Send updates at a fixed tick rather than on every frame or input event:
let pending = null;
function queueState(s) { pending = s; }
setInterval(() => {
if (pending) { room.setState(pending); pending = null; }
}, 1000 / 15); // 15 Hz leaves headroom for send() eventsMessage design
Keep it small. Short keys, rounded numbers, integers where you can. Send deltas instead of the whole world when the world is big.
Version it. Players on different versions of your game can share a room. Include
v: 1and ignore versions you don't understand.Type it. Include a
typefield and ignore types you don't know.Validate everything. Anyone in the room can send anything. Check shapes, ranges and whose turn it is before you act on a message.
Use
setStatefor "where I am now" (a position, the current world) andsendfor "this just happened" (a shot fired, a chat line, an input). Late joiners getsetStatesnapshots;sendevents are gone once they've been delivered.Don't assume anything reaches players who haven't joined yet. Only the latest snapshot per connected peer is kept.
Design for a non-authoritative relay
The relay forwards messages. It never checks, simulates or stores your game. Every client can send any payload it likes, and a modified client can claim any position, health, score or result. What that means depends on the kind of game.
Game type | Suggested model | Why |
|---|---|---|
Co-op, social, drawing, shared cursors, casual racing | Peer-broadcast. Each client | Cheating only hurts the cheater's own fun. It's the simplest model. |
Competitive real-time (arena, .io-style, PvP) | Host-authoritative. One peer simulates; the others send inputs. | Honest players are protected from each other. It also uses far less bandwidth. |
Host-authoritative
Pick a host that everyone agrees on. A reliable way to do this is to have the host announce itself inside the world state it publishes (
world.host = peerId). A player joining an empty room becomes host. A player joining a busy room reads the host fromjoined.snapshots.Clients send inputs, not results. Send
room.send({ type: "input", dir: "left" }), never "I'm at (x, y)" or "I hit you".The host simulates and publishes. It checks each input (right sender, allowed move, cooldowns), advances the game, and calls
room.setState(world)at a fixed rate. Late joiners get the host's snapshot as soon as they join.Other clients render the host's state. Use prediction to hide latency (next section).
Migrate the host. On
peerLeft, if the host left, every client picks the next host with the same rule. For example: the first id in the world's player order that is still connected, or else the lowestpeerIdamong the remaining peers. The new host picks up from the last world snapshot it received.
A host-authoritative design protects players from each other, but not from a cheating host. If you need to limit that:
Pick the host by a rule that no single player controls.
Have other clients spot-check the host's decisions they can verify (for example, that a move was legal) and leave or flag a room whose host breaks the rules.
Anything that awards platform value (ratings, rewards, verified scores) should go through a server-side feature rather than trusting the room. See Ranked matchmaking and Scores, leaderboards & anti-cheat.
The platform doesn't do host migration for you. It only tells you when peers join and leave.
Lockstep (deterministic)
Every client runs the same deterministic simulation and only inputs travel over the relay. For turn-based games this is natural: every client applies every move in the same order and rejects illegal ones. For real-time lockstep you need fixed timesteps, a seeded RNG, no floating-point differences between clients, and agreement on input order. The relay doesn't order inputs across senders, so the game has to (for example, "turn N belongs to player N mod k", or the host stamps the input order).
You can detect desyncs by exchanging a hash of your state every few turns. When the hashes differ, someone's client has either a bug or a cheat.
Client-side prediction
With a host-authoritative model, a client that waits for the host before moving feels laggy. Instead:
Apply your own input locally right away and give it a sequence number.
Send the input to the host with that number.
The host includes "last input processed per player" in its world state.
When a new world arrives, snap to the host's state and replay any of your inputs it hasn't processed yet.
Interpolate other players between their last two known states.
Anti-cheat checklist
Never trust a payload's claim about who sent it. Use the
peerIdthe relay attaches to eachstateandmessageevent.Never trust a name.
nameisn't verified.Check whose turn it is, value ranges and message rate in your own logic. A modified client can send up to the relay's limits.
Don't send a platform score (
submitScore,gameOver) based on what other players told you. Only count what your own client (or the host you trust) actually simulated. Use Scores, leaderboards & anti-cheat for leaderboards that count.Ordinary room names are public in
listRooms(). Don't put secrets in them.
Bandwidth and scaling
The relay sends each message to every other peer. With N peers each sending R messages per second, the relay delivers about N × (N − 1) × R messages per second in that room. 64 peers at 30/s is about 121,000 deliveries per second. A single host broadcasting the world at 20 Hz to 63 others is about 1,260. Prefer:
a host broadcasting world state, over everyone broadcasting;
10–20 Hz ticks, over per-frame updates;
deltas and area-of-interest filtering, over full-world state.
Worked example: "Race to 21" (2–4 players, host-authoritative)
A small turn-based game. Players take turns adding 1, 2 or 3 to a shared total, and whoever makes it reach 21 loses. It shows the lobby join, host election, validated inputs, world snapshots for late joiners, and host migration.
<!doctype html>
<html>
<body>
<div id="status">Connecting…</div>
<div id="total" style="font-size:48px">0</div>
<div id="players"></div>
<button data-n="1">+1</button>
<button data-n="2">+2</button>
<button data-n="3">+3</button>
<button id="start">Start game</button>
<script>
const SDK = window.GameSDK;
const MAX_PLAYERS = 4, TARGET = 21, PREFIX = "r21-";
let room = null;
let me = null; // my peerId
let world = null; // { v, host, phase, order, names, turn, total, loser }
const $ = (id) => document.getElementById(id);
const isHost = () => world && world.host === me;
// ---------- lobby: fill the busiest open table, or open a new one ----------
async function join() {
const player = await SDK.getPlayer();
const name = (player && player.handle) || "guest";
const list = await SDK.listRooms();
if (!list) { $("status").textContent = "Multiplayer is unavailable."; return; }
const open = list.rooms.find((r) => r.room.startsWith(PREFIX) && r.peers < MAX_PLAYERS);
const roomName = open ? open.room : PREFIX + Math.random().toString(36).slice(2, 8);
room = SDK.connectRoom({ room: roomName, name });
room.on("joined", ({ peerId, peers, snapshots }) => {
me = peerId;
const snap = snapshots.find((s) => s.data && s.data.v === 1 && s.data.host);
if (snap) {
world = snap.data; // late join: adopt the host's world
} else if (peers.length === 0) {
world = newWorld(); // empty room: I'm the host
world.names[me] = name;
world.order.push(me);
publish();
}
// otherwise: a host exists but hasn't published yet – wait for "state"
render();
});
room.on("peerJoined", ({ peerId, name }) => {
if (!isHost()) return;
world.names[peerId] = String(name).slice(0, 32);
if (world.phase === "lobby" && world.order.length < MAX_PLAYERS) world.order.push(peerId);
publish(); // extra joiners are spectators
});
room.on("peerLeft", ({ peerId }) => {
if (!world) return;
const hostLeft = world.host === peerId;
removePlayer(peerId);
if (hostLeft) {
// Every client applies the same rule: first remaining player in turn order,
// else the lowest connected peerId.
const connected = new Set([me, ...Object.keys(room.peers)]);
const next = world.order.find((id) => connected.has(id)) || [...connected].sort()[0];
world.host = next;
}
if (isHost()) publish();
render();
});
// Only the host's world is trusted. Ignore state from anyone else.
room.on("state", ({ peerId, data }) => {
if (!data || data.v !== 1) return;
if (world && peerId !== world.host) return;
if (!world && data.host !== peerId) return;
world = data;
render();
});
// Inputs travel as messages. Only the host acts on them.
room.on("message", ({ peerId, data }) => {
if (!isHost() || !data || data.v !== 1) return;
if (data.type === "take") applyTake(peerId, data.n);
if (data.type === "start" && peerId === world.order[0]) startGame();
});
room.on("error", ({ code }) => {
if (code === "room_full") join(); // lost a race for the last slot
else $("status").textContent = "Connection problem: " + code;
});
room.on("close", () => { $("status").textContent = "Disconnected."; });
}
// ---------- host-side rules (the single source of truth) ----------
function newWorld() {
return { v: 1, host: me, phase: "lobby", order: [], names: {}, turn: 0, total: 0, loser: null };
}
function startGame() {
if (world.phase === "playing" || world.order.length < 2) return;
Object.assign(world, { phase: "playing", turn: 0, total: 0, loser: null });
publish();
}
function applyTake(peerId, n) {
if (world.phase !== "playing") return;
if (peerId !== world.order[world.turn]) return; // not your turn
if (![1, 2, 3].includes(n)) return; // illegal amount
world.total = Math.min(TARGET, world.total + n);
if (world.total >= TARGET) {
world.loser = peerId;
world.phase = "over";
} else {
world.turn = (world.turn + 1) % world.order.length;
}
publish();
}
function removePlayer(peerId) {
const i = world.order.indexOf(peerId);
if (i === -1) return;
world.order.splice(i, 1);
if (i < world.turn) world.turn--;
if (world.order.length === 0) { world.turn = 0; return; }
world.turn %= world.order.length;
if (world.phase === "playing" && world.order.length < 2) world.phase = "over";
}
function publish() {
room.setState(world); // broadcast + retained for late joiners
render(); // the relay never echoes to the sender
}
// ---------- client actions ----------
function take(n) {
if (!world) return;
if (isHost()) applyTake(me, n); // the host applies its own input
else room.send({ v: 1, type: "take", n }); // everyone else asks the host
}
document.querySelectorAll("[data-n]").forEach((b) => {
b.onclick = () => take(Number(b.dataset.n));
});
$("start").onclick = () => {
if (isHost()) startGame(); else room.send({ v: 1, type: "start" });
};
// ---------- rendering ----------
function render() {
if (!world) { $("status").textContent = "Joining…"; return; }
$("total").textContent = world.total;
$("players").textContent = world.order
.map((id, i) => (i === world.turn && world.phase === "playing" ? "▶ " : "") +
(world.names[id] || "player") + (id === me ? " (you)" : "") + (id === world.host ? " ★" : ""))
.join(" · ");
const myTurn = world.phase === "playing" && world.order[world.turn] === me;
document.querySelectorAll("[data-n]").forEach((b) => { b.disabled = !myTurn; });
$("start").disabled = !(world.phase !== "playing" && world.order[0] === me && world.order.length >= 2);
$("status").textContent =
world.phase === "lobby" ? `Waiting for players (${world.order.length}/${MAX_PLAYERS})` :
world.phase === "over" ? (world.loser === me ? "You hit 21. You lose!" :
world.loser ? `${world.names[world.loser]} hit 21!` : "Game over") :
myTurn ? "Your turn" : "Waiting…";
}
GameSDK.ready();
join();
</script>
</body>
</html>What this example shows:
Lobby placement with
listRooms()and a room-name prefix, plus a retry when the room fills up between listing and joining.A single source of truth. Only the host changes
world. Everyone else sends inputs and ignores state from anyone but the host.Late joiners get the host's world from
joined.snapshots. Nobody has to resend it.Host migration. When the host leaves, everyone applies the same rule, and the new host publishes from the last world it saw.
A game-level player cap of 4 on top of the relay's 64. Anyone past 4 watches.
Still missing before this is production-ready: a turn timer so a player who goes quiet can't stall the table, a reconnect path (see Reconnection), and a plan for two players who briefly both think they're host after a simultaneous disconnect. A simple fix for that last one is to accept the world from whichever host has the lower peerId.
Related
Ranked matchmaking: 1v1 skill-based pairing, server-owned turns and clocks, and persistent ratings.
GameSDK multiplayer & ranked reference: every method, event and error code. - How games run: the sandbox and how the SDK talks to the page. - Packaging & publishing: turning realtime on for a version. - Scores, leaderboards & anti-cheat: verified scores that a client can't forge. - Errors & limits: platform-wide limits.