Skip to content
Cassette

Multiplayer: host-less rooms in a few lines

Online page: https://cassette.gg/agents/multiplayer.md. Fetch it again before you act on it; don't work from a saved copy.

Rooms are advertised as on here; relay reachability is not verified. Check https://cassette.gg/v1/config (gameServices.roomsConfigured). This flag says whether rooms are configured, not whether the relay is reachable; a room operation can still fail with unavailable. If config cannot be read, treat rooms as off.

cassette.rooms gives a game real-time multiplayer: players find a room by a 5-letter code, an invite link or quick match, and game data travels between their browsers, through Cassette's servers where it has to. The game's metadata must declare online play (players.modes with an online-* entry, or contentRating.interaction.multiplayer). cassette.rooms.available is false where rooms aren't on.

Your game calls send and listens for message. That works from the moment a room opens, on any connection, with nothing to wait for. There's no key, connection or transport for a game to manage.

CRITICAL RULE FOR AGENTS: make multiplayer host-less using the drop-in primitives (no big rewrite)

When adding or upgrading multiplayer on Cassette, NEVER leave a match where the game freezes, resets, or ends if the "host" player closes their tab. You do NOT need to rewrite the game's architecture to make it host-less. Pick the lightest primitive that fits the codebase:

  1. Existing room.send / room.on('message') or if (room.isHost) games (3 lines added, 0 rewrite): keep the game's state variable and handlers, and register:
    room.checkpoint(() => gameState, (restored) => { gameState = restored })
  2. New games or shared-state games (5 lines, plain JS object): const state = room.shared({ wave: 1, bossHp: 500, score: 0, players: {} }, { counters: ['score'] }). Mutate state.players[room.self.id] from each client and shared keys inside if (room.isHost).

Managed dedicated servers for 32–64 players and server-authoritative anti-cheat are coming soon; don't stand up external servers unless the human explicitly asks for more than 8 players today.

The room lifecycle

Quick match only fills a public room with the same queue, version and maxPlayers. create() rooms are private by default; set public: true for a room that quick match should find. If none exists, quick match creates a public room itself.

await cassette.rooms.create({ public: true, maxPlayers: 4 })
await cassette.rooms.quickMatch({ maxPlayers: 4 })

room.players is the current roster, including yourself; each player has seat, id, username, away and host. room.on('join', player => ...) and room.on('leave', player => ...) notify membership changes; read room.players again to render the lobby. await room.invite() opens the shell's share sheet and returns a boolean, not a URL. The shared link is https://cassette.gg/play/<slug>?room=<CODE>; cassette.rooms.invited contains the code on opening it, or null.

The first successful room open in a browser profile shows Cassette's IP notice in the outer shell. The player chooses Use direct connections or Keep it private. The room can open while the notice is visible; keep the game responsive and let the player answer the shell. A pending operation can also be waiting for sign-in or the network: show a waiting state and don't retry in a loop.

This complete minimal lobby uses the SDK shim and sends a greeting. Declare online multiplayer in the game's metadata first:

<script src="https://cassette.gg/sdk/v2-shim.js"></script>
<button id="open">Create room</button><button id="invite" disabled>Invite</button><pre id="status"></pre>
<script>
Cassette.ready.then(async () => {
  Cassette.started()
  const c = await Cassette.init(), status = document.querySelector('#status'), invite = document.querySelector('#invite')
  async function open(code) {
    status.textContent = 'Waiting for room; answer the Cassette notice if shown.'
    try {
      const room = code ? await c.rooms.join(code) : await c.rooms.create({ maxPlayers: 4 })
      const render = () => { status.textContent = `Room ${room.code}: ${room.players.map(p => p.username || p.id).join(', ')}` }
      room.on('join', render); room.on('leave', render)
      room.on('message', (msg, from) => { status.textContent += `\n${from.username || from.id}: ${msg}` })
      invite.disabled = false; invite.onclick = () => room.invite()
      render(); room.send('Hello!')
    } catch (e) { status.textContent = `Could not open room: ${e.code || e.message}` }
  }
  if (!c.rooms.available) status.textContent = 'Rooms unavailable here.'
  else { document.querySelector('#open').onclick = () => open(); if (c.rooms.invited) open(c.rooms.invited) }
})
</script>

create() and join() can reject with RoomError.code: signed_out, not_found, full, locked, version_mismatch, not_declared (online play is missing from metadata), rooms_off, banned, rate, unavailable or unsupported_browser. unavailable includes relay trouble and account, IP or capacity limits; banned includes a room restriction or missing access to a paid game. Show a useful message and let the player retry later where appropriate. A send after the room closes throws closed; other send errors include too_large and no_such_player.

const cassette = await Cassette.init()
const room = await cassette.rooms.create({ maxPlayers: 4, props: { map: 'dunes' } })
// or: await cassette.rooms.join(code) (from an invite link: cassette.rooms.invited) or await cassette.rooms.quickMatch({ queue: 'ranked' })

The host can lock() the room when the match starts and kick(playerId); anyone can chat(text) (Cassette filters it) and block(playerId) (stops that person's chat and direct messages everywhere, saved to the account) or unblock(playerId). Receive chat with room.on('chat', (text, from) => ...); Cassette filters it and limits sends to one per second and 300 characters. Invalid text reports bad_chat; sending too fast reports rate through on('error', code).

room.me is your seat number; room.self is your player, room.hostPlayer is the current host, and room.info has the room's code, maxPlayers, locked, public, queue and props. Listen for away and back to update the roster when another player disconnects and returns. on('close', reason) ends your room session (left, kicked, idle, expired, restart, banned, closed_by_staff, lost, …); stop the match UI and offer create/join again. leave() leaves the room. The host can change shared room metadata with setProps(props); everyone receives on('props', props) and can read room.info.props. Each player sets their own lobby data with setMyProps(props); everyone receives on('player-props', player), including the player's player.props (for example { ready: true }). The host can reopen a locked room with unlock().

One signed-in account can be in one room at a time; rejoining that same room resumes its seat. Current relay defaults also limit one source IP to 10 connections, 4 rooms and 56 reserved fanout slots. Global room and connection capacity limits apply too; capacity refusals use unavailable. The relay defaults are configurable.

A dropped connection isn't a leave: the seat is held for 20 seconds and the SDK resumes it (reconnecting, reconnected). A player keeps the same seat for the life of the room, across reconnects, reloads and even leaving and coming back, so seats are safe to use as addresses. When the host's connection drops or they leave, the role moves at once to whoever has been in the room longest. If the host's page stops running with its connection still open (a hung tab, say), the role moves after about 15 seconds. That player keeps their seat, and if their page comes back they're an ordinary player. Everyone gets host no later than the old host's leave.

If the relay itself restarts, its in-memory rooms end. The play shell shows a plain message and a Return to lobby button; create or join a fresh room there. A socket handshake times out after five seconds, and a connection that stops replying is retried after fifteen seconds. Handle the room's close event too: stop the match UI and restore your own create/join controls. Host migration preserves state only while another player still holds it; it is not a persistent game server.

Signing uses native Ed25519 when available and a bundled implementation when it isn't, including older Chrome and Safari. Both paths sign and verify the same frames; neither permits unsigned play. A secure browser context is still required. Chromium, Firefox and WebKit are covered by the local recovery fixture; real iPhone sleep/wake needs a device check before a demo.

Messages: send and on('message')

room.send({ type: 'input', dx: 1 })                    // everyone else (default)
room.send({ type: 'join-team' }, { to: 'host' })       // whoever is host right now
room.send(stateBytes, { to: otherPlayer.id })          // one player
room.send(datagram, { to: otherPlayer.id, unreliable: true })  // may be lost or reordered, never retransmitted
room.on('message', (data, from) => { /* from: seat, id, username, away, host */ })

JSON-able values arrive as such; ArrayBuffer and typed arrays arrive as Uint8Array. A message is at most 16 KB (more throws RoomError('too_large')). A send to the host from the host, or to yourself, arrives through on('message') too, so the same code runs on every peer.

By default messages from one player arrive in order, and a message to 'host' isn't lost when the host changes. unreliable: true is for a game with its own acks or for state that's stale by the next tick (a netcode library carrying its own datagrams, say). Keep those to 1,060 bytes or less so one frame fits one network packet (measured on Chromium); bigger ones are split, and losing any piece loses the frame.

room.transport(player.id) returns 'p2p', 'turn', 'relay' or 'none'. Nothing needs to ask, but it's there to show a hint or to prove what a test used. Frames take a direct link when one is open and Cassette's WebSocket relay until then, or for good if it never opens.

Drop-in A: checkpoint on an existing game

let gameState = { wave: 1, enemies: [], players: {} }   // the game's existing state

const stopCheckpoint = room.checkpoint(() => gameState, (restored) => { gameState = restored })

room.on('message', (msg, from) => {
  if (msg.type === 'input' && room.isHost) stepPlayer(gameState, from.id, msg)
  if (msg.type === 'state' && !room.isHost) gameState = msg.state
})
setInterval(() => {
  room.send(readInput(), { to: 'host' })
  if (room.isHost) { stepEnemies(gameState); room.send({ type: 'state', state: gameState }) }
}, 50)

While this player is host the SDK snapshots getState() every 250 ms ({ intervalMs } to change it) and sends it to the others. checkpoint() returns a function to stop snapshots; call it when this game state is no longer in use. When someone else takes over: their setState runs with the latest snapshot they hold, then room.isHost turns true, then the { to: 'host' } messages the snapshot didn't include arrive through on('message'), each once, then host fires. State must be JSON. Bigger states travel in pieces, up to 4 MB; past that you get an error event with checkpoint_too_large and no snapshot. If a peer is on the relay, big snapshots slow down to fit its limit instead of disconnecting anyone.

What a host change costs: anything since the last snapshot that wasn't an input to the host (up to one intervalMs).

Drop-in B: shared for a live object

const state = room.shared({ wave: 1, boss: { x: 400, hp: 500 }, score: 0, players: {} }, { counters: ['score'] })

state.players[room.self.id] = { x: 100, y: 300, firing: false }  // yours alone
function frame() {
  const me = state.players[room.self.id]
  me.x += readDx() * 4
  if (room.isHost) { state.boss.hp -= damage(); state.wave += 1 } // host-owned keys
  state.score += 5                                                // counters add up from every peer
  render(state)
  requestAnimationFrame(frame)
}

state.players[<playerId>] is writable only by that player, keys in counters add up from everyone, and every other key belongs to the current host. A write you don't own doesn't happen, on your machine or anywhere else. Peers refuse it on arrival too, so a modified client can't do it either. Changes go out at most every 50 ms ({ tickMs }), the last value winning, and arrive reliably. Someone joining or returning gets the whole state from the host. A leaver's slice goes when they leave. Shared-state updates do not fire a room event: render the state in your animation loop or on a timer, including when another player changes it. Values are JSON. One per room.

What players are told, and what games can't do

The player's browser, not your game, holds the connections. Your game never sees another player's IP address, a network candidate or a TURN credential.

By default game traffic goes through Cassette's servers and other players can't see a player's IP address. A player can turn on Direct connections in Cassette's Players menu for lower lag, which shows their IP address to the room. Your game has nothing to do for either, and can't change it.

Reporting cheating

const result = await room.report(playerId, 'cheating') // or 'chat'
// 'filed' | 'cancelled' | 'failed' | 'no_evidence'

This opens Cassette's own sheet with that player filled in. Nothing is filed unless the person confirms it there. Players can also report from Cassette's Players menu with no code in your game. Someone who just left can still be reported for cheating, for about 90 seconds. A cheating report carries about the last 90 seconds of that player's messages and the reporter's, up to 384 KB each: the newest ones, and the first of each few seconds before them. Moderators check it and decide.

Every message is signed by a key made for that room, in the player's browser, outside your game's reach. Here's what that shows and what it doesn't:

  • It shows that this player's session, running this build, sent these bytes in this match. A report can't be faked: frames no key in that match signed count for nothing.
  • It can catch, from signatures alone: two different messages under one number (EQUIVOCATION, which needs two players' reports to see), and a host-only frame from someone who wasn't host (AUTHORITY_SPOOF). A reconnect or a reload can't cause these. Only a modified browser can. A player's browser keeps any such frame it receives, however much that player sends afterwards.
  • It doesn't show intent. Your game's code runs in the player's session and could send anything, so a moderator still has to judge whether the bytes are cheating, against what your game does.
  • It can't see a player reading state their browser already received (maphacks), or why a connection dropped.

A restriction from a cheating report applies to that game. A person always makes the decision.

Limits

Up to 8 players per room, within the game's declared maximum. 16 KB per message. Room codes are 5 characters with no vowels. Rooms idle out after 10 minutes.

Up to 6 players, everyone links to everyone. Above that, the host and the next in line link to everyone and the rest link to those two; other traffic takes the relay. Your code doesn't change.

On the relay each player can send about 57 KB a second, counting 96 bytes per message, and up to 27 relay messages a second (many small messages share one). Reliable messages wait for room. Unreliable ones past the limit are dropped, and your game gets one error event with relay_limit per room. Links aren't held to that limit. A player's browser takes up to 1,500 messages and 4 MB a second from one player on any link, and a link through Cassette's TURN server (the default until a player chooses direct connections) carries about 128 KB a second.

Other error codes: rate (back off, don't retry in a loop), no_such_player.

Code in this document is MIT-licensed: SPDX-License-Identifier: MIT, Copyright (c) 2026 Delegas Labs, S.L. Copy and use it in your games freely.