# 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:
   ```ts
   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.

```ts
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:

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

```ts
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')`

```ts
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

```ts
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

```ts
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

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