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, oldest first: [{ 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",      ({ code, reason, reconnectable }) => { /* see Reconnection */ });

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.

The joined.peers array is in join order, oldest first, and never includes you. That's a stable property of the relay, so it's safe to use: peers[0] is the longest-connected player in the room, which makes it a serviceable way to pick a host (see Host-authoritative). room.peers is an object, so it has no meaningful order — use the array from joined when order matters.

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.

What close tells you

The close event carries { code, reason, reconnectable }:

Field

Meaning

code

The WebSocket close code. 1006 is a connection dropped with no close frame — the usual shape of an edge or network drop. 1000/1001 are ordinary closes. 4401 and 4409 are the relay's own codes (see Private match rooms).

reason

The close reason string, or "" (usually empty).

reconnectable

true for 1006 and 1001 — a drop you should retry. false for everything else, including 4401 ticket_required and 4409 (the same player connected again somewhere else).

Branch on reconnectable rather than on the raw code: a loop that reconnects after 4409 fights the player's other tab, and one that reconnects after 4401 just burns attempts until you fetch a fresh ticket.

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", ({ reconnectable }) => {
    if (fatal || !reconnectable || 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.

Idle connections and the automatic keep-alive

An idle room connection is dropped by the edge at about 125 seconds. We measured 125.1 s, 125.2 s and 125.1 s over three runs: the socket closes with code 1006 and no close frame, exactly as if the network had gone away. It doesn't matter that the relay is healthy or that other players are still in the room — what matters is that this connection sent nothing.

The SDK handles this for you. connectRoom() sends { t: "ping" } every 45 seconds for the life of the connection; the relay answers pong, which the SDK swallows, so no message event reaches your game. A connection left completely idle this way survived a full 20-minute test in all three engines. The interval is cleared when the socket closes or you call room.close(), so there's nothing to tidy up.

You don't need to send your own keep-alive, and you shouldn't: your traffic counts towards the 30-messages-per-second cap and the SDK's ping already does the job. If you are writing your own client against the relay rather than using the SDK, any traffic at 60-second intervals or shorter keeps the connection alive.

What this doesn't fix: a connection that has quietly stalled (the network is gone but the socket hasn't noticed) still isn't visible to your game until the browser finally closes it. If your game needs to know a peer is really there, send something at your own cadence and time peers out on your side.

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

Both codes arrive on the close event as { code, reason, reconnectable }, with reconnectable: false. Treat 4401 / ticket_required as "fetch a fresh ticket": call GameSDK.ranked.match(id) and connect again with the new roomTicket. Treat 4409 as "another tab or device has the seat" and don't reconnect at all — doing so would kick the player's other connection, which would then kick this one back. 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

Over the cap, messages are dropped silently — the connection stays open. There is no queue and no retry: the relay discards the frame, sends you an error with code rate_limited, and carries on. Nobody else in the room learns that anything was lost, and your own game only learns if you listen for error. A burst that pushes you over the cap therefore shows up as a gap in your game state, not as a disconnect, which is why an unthrottled setState on every animation frame produces players who teleport rather than an obvious failure.

Listen for it while you're building:

room.on("error", ({ code }) => {
  if (code === "rate_limited") console.warn("over the send cap — messages are being dropped");
});

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.

Ordering: what you can rely on

At or below the 30-per-second cap, delivery from one sender is FIFO and lossless. We sent 2,000 numbered messages at 24.4/s through the production relay: all 2,000 arrived, in strictly increasing order, none dropped, none duplicated. Each peer has one WebSocket and the relay fans out in the order it reads, so you can rely on this: if you send({ n: 1 }) then send({ n: 2 }), every other peer sees 1 before 2.

Two things that guarantee doesn't cover:

  • Ordering between senders. Two peers' messages can interleave any way at all. Never infer "who acted first" from arrival order — have the host decide, or carry your own tick number.

  • Anything above the cap. See below.

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?