---
title: "Multiplayer rooms"
description: "For games. Rooms are real-time sessions over one WebSocket: quick match, private rooms with a 6-letter code, a public lobby, message relay, shared room state, per-player state, host migration and…"
url: "/docs/guides/multiplayer/"
lang: en
index: "/llms.txt"
---
# Multiplayer rooms

**For games.** Rooms are real-time sessions over one WebSocket: quick match, private rooms with a 6-letter code, a public lobby,
message relay, shared room state, per-player state, host migration and reconnection. No server code to write.

## Join a room

```js
const room = await kumo.rooms.quickMatch('duel');          // resolves when a room is ready
// or
const room = await kumo.rooms.create('duel', { private: true });
showCode(room.code);                                       // e.g. "K7QM3X"
// the other player:
const room = await kumo.rooms.join('k7qm3x');              // room id or code, case-insensitive
```

| Method | Notes |
|---|---|
| `rooms.quickMatch(mode)` | joins the fullest open public room of that mode, or waits in a queue until `minPlayers` are ready (or `fillTimeoutSeconds` pass) |
| `rooms.cancelMatch()` | stop waiting (the pending `quickMatch` rejects with `match_cancelled`) |
| `rooms.create(mode, opts)` | `private`, `maxPlayers`, `metadata` (≤ 2 KB, shown in the lobby), custom `code`, `hostOnlyState` |
| `rooms.join(idOrCode)` | `room_not_found`, `room_full`, `room_locked` |
| `rooms.list(mode?)` | open public rooms for a lobby, most players first |

Modes come from config. A project without modes gets a built-in `default` mode (2–8 players) so two browser tabs
can meet before you write any config:

```json
{ "multiplayer": { "modes": [
  { "key": "duel", "minPlayers": 2, "maxPlayers": 2, "fillTimeoutSeconds": 20 },
  { "key": "party", "minPlayers": 1, "maxPlayers": 8, "fillTimeoutSeconds": 0 }
] } }
```

## Talk

```js
room.send('move', { x, y });                     // to everyone else (fire-and-forget, ordered)
room.send('hit', { dmg: 3 }, { to: [targetId] }); // to specific players
room.on('message', ({ from, type, data }) => { … });

await room.setState({ round: 2 });               // shared state: everyone (you too) gets 'stateChanged'
await room.setMyState({ ready: true, skin: 'red' }); // your player state: 'playerStateChanged'
room.state;          // current shared state
room.players;        // [{ id, displayName, connected, joinedAt, state }]
```

State patches are applied in the same order on every client (a `null` value deletes a key), so all clients agree.
Use `send` for high-frequency data (positions), state for things late joiners must see (scores, seats, settings).

## Host

`room.hostId` / `room.isHost`: the earliest-joined **connected** player. If the host leaves or drops, the next player
becomes host immediately (`hostChanged`), and a returning player never takes it back. Use the host for authority:
simulate on the host and broadcast snapshots. With `hostOnlyState: true`, only the host can `setState`.
`room.lock()` (host only) stops new players from joining a match in progress.

## Events

| Event | Payload |
|---|---|
| `message` | `{ from, type, data }` |
| `playerJoined` / `playerLeft` | player / `{ playerId, reason: 'left' \| 'timeout' }` |
| `playerDisconnected` / `playerReconnected` | `{ playerId }` — their seat is kept while they reconnect |
| `stateChanged` | `{ patch, state, by, version }` |
| `playerStateChanged` | `{ playerId, patch, state }` |
| `hostChanged` | `{ hostId, previousHostId }` |
| `reconnecting` / `resumed` | your own connection dropped / came back (state refreshed from a snapshot) |
| `closed` | `{ reason }` — `left`, `seat_expired`, `connection_lost`, `replaced`, `shutdown`, `unauthorized` |

`kumo.rooms.on('connection', state => …)` reports `connecting`, `open`, `reconnecting`, `closed`.

## Reconnects and page reloads

The SDK reconnects automatically with backoff; the server keeps your seat for **20 seconds**. After a page reload the
`Room` object is gone but the seat is not:

```js
const held = await kumo.rooms.fetchHeldRoom();   // { roomId, mode, code, expiresInMs } or null
if (held) room = await kumo.rooms.rejoin();       // back in, state restored from a snapshot
```

Or just start something new — `create`, `join` or `quickMatch` release the old seat as a normal leave.

## Peer-to-peer mode

By default every message goes through KUMODeck's servers. A mode with `"transport": "p2p"` instead sends game
messages **directly between players over WebRTC**. KUMODeck still does the tedious parts: matchmaking, room codes,
joining and leaving, choosing the host, relaying the WebRTC handshake, and handing out short-lived TURN relay
credentials. Your code does not change:

```json
{ "key": "coop", "minPlayers": 2, "maxPlayers": 4, "transport": "p2p", "p2p": { "maxPlayers": 4, "relay": "fallback" } }
```

```js
const room = await kumo.rooms.quickMatch('coop');
room.transport;                                   // 'p2p'
room.send('pos', { x, y }, { reliable: false });  // unordered, no retransmit: good for positions
room.on('peer', ({ playerId, state, relayed }) => { … }); // connecting | connected | failed | closed
room.peers;                                       // [{ playerId, state, relayed }]
```

The SDK connects everyone to everyone (a mesh), uses the TURN relay as `p2p.relay` says (below), and keeps
shared state working when the host leaves: the server picks the next host (same rule as above), which continues
from its copy of the state, and unconfirmed `setState` calls are resent to it.

> **Warning — no referee.** In peer-to-peer rooms the host's browser decides the shared state, and a modified client can
> send anything. **Use the default server transport for competitive, prize or paid matches.**

> **Warning — IP addresses.** A direct WebRTC connection shows each player's IP address (roughly where they live) to the
> others. Choose with `p2p.relay`:

| `p2p.relay` | What happens | Gives up |
|---|---|---|
| `always` | every message goes through the TURN relay; only the relay's address is visible; connects wherever the relay is reachable | relay traffic is billed by the amount sent |
| `fallback` | direct first; the relay only when a direct connection fails | players see each other's IP address when direct |
| `never` | direct only; never any relay cost | players who cannot reach each other directly fail to connect (`peer` event `state: 'failed'`) |

Which to pick, and the price per match of each way, is in [Play online together](/docs/guides/play-online/index.md): the
`multiplayer` Skill asks the creator once and picks it.

| | |
|---|---|
| Players | 2–8 per mesh (`p2p.maxPlayers`, default 4 — every player uploads to every other player). A full mesh refuses joins with `p2p_room_full` (`details.max`) |
| Cost | relay traffic is billed at cost as `turn_egress_bytes` (no markup); signaling is tiny |
| Errors | `webrtc_unavailable` (no WebRTC in this environment), `turn_unavailable` (a relay is required but not available), `setState` rejects with `timeout` if the host cannot be reached |
| State | the same limits as server rooms (64 KB shared, 8 KB per player); it disappears when everyone leaves |

To try it, open the game in two different browsers (or one normal and one private window), quick match the same
`p2p` mode, and check `room.peers` shows `connected` in both.

## Limits

| Limit | Value |
|---|---|
| Messages | 30/s per connection (burst 60); excess is dropped with a `warning` event |
| Message size | 16 KB (`payload_too_large`) |
| Shared state | 64 KB total; player state 8 KB each |
| Players per room | up to 64 (per mode) |
| Idle socket | closed after 10 minutes outside any room (reconnects on the next call) |

One browser profile = one player: a second tab replaces the first tab's connection. To test multiplayer on one
machine, give each tab its own storage (the templates use `?player=2`).

> **Note** Rooms relay and synchronise; they do not run your game logic on the server. Competitive games should be
> host-authoritative (see `examples/sky-duel`), or run the match logic in your own [Functions](/docs/guides/functions/index.md)
> (Durable Objects give you stateful WebSocket rooms).
