Guides

Real-time multiplayer

AdminUpdated Sep 22, 2026

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: true in the publish options.

  • CLI: --realtime, or "realtime": true in game.json.

  • REST: realtime: true in 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 peerId from the server.

State (setState)

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 (send)

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

room

"default"

The room name. Must match ^[A-Za-z0-9_-]{1,64}$, or the server sends bad_room and closes the connection.

name

"player"

A display name that other peers see. The server shortens it to 32 characters. It is not verified. See Player identity.

ticket

none

Only for private match rooms: the roomTicket from a ranked match. Ordinary rooms ignore it. See Private match rooms.

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 is null until joined fires.

  • room.peers: an object of the other connected peers, keyed by peerId and 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.

  • peers is 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 a pub- 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 peerId is per connection. If a player reloads or reconnects, they get a new peerId.

  • name is 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, close fires. Everyone else gets peerLeft for your old peerId, and the relay throws away your saved snapshot.

  • To come back, call connectRoom() again with the same room name. You get a new peerId, and the others see a peerJoined.

  • joined.snapshots catches 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.room is match-<match id>. Every room name starting with match- is private.

  • Ticket. match.roomTicket is 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's null once 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 peerLeft for the old peerId, then peerJoined for 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

error ticket_required, then close

4401

The same player connected again elsewhere (the old connection)

close only

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: error room_full, then close.

Open rooms per game

200

Creating another room is refused: error game_room_limit, then close. Joining an existing room still works.

Open rooms across the whole relay

10,000

Creating a room is refused: error server_full, then close.

Concurrent connections per IP address

20

error server_full, then close. Players on one shared network (a school or office) share this limit.

Message size (the whole encoded frame)

8 KB

That message is dropped and you get error too_large. The connection stays open. See the note below the table.

Messages per second, per connection

30 (rolling 1-second window)

That message is dropped and you get error rate_limited. setState and send count together.

Retained snapshot per peer

8 KB of JSON

A larger setState is still broadcast, but the relay keeps your previous snapshot for late joiners.

Snapshot lifetime

Until you disconnect

Snapshots disappear when their peer leaves.

Rooms returned by listRooms()

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() events

Message 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: 1 and ignore versions you don't understand.

  • Type it. Include a type field 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 setState for "where I am now" (a position, the current world) and send for "this just happened" (a shot fired, a chat line, an input). Late joiners get setState snapshots; send events 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 setStates its own state.

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

  1. 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 from joined.snapshots.

  2. Clients send inputs, not results. Send room.send({ type: "input", dir: "left" }), never "I'm at (x, y)" or "I hit you".

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

  4. Other clients render the host's state. Use prediction to hide latency (next section).

  5. 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 lowest peerId among 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:

  1. Apply your own input locally right away and give it a sequence number.

  2. Send the input to the host with that number.

  3. The host includes "last input processed per player" in its world state.

  4. When a new world arrives, snap to the host's state and replay any of your inputs it hasn't processed yet.

  5. Interpolate other players between their last two known states.

Anti-cheat checklist

  • Never trust a payload's claim about who sent it. Use the peerId the relay attaches to each state and message event.

  • Never trust a name. name isn'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

Was this page helpful?
Real-time multiplayer