# relay-host reference

Create a game room from a browser or Node 22+. The helper handles roster changes, compact input, peer links and relay fallback. Import from `@controller-plus/sdk/relay-host` or `https://controller.plus/sdk/src/relay-host.mjs`.

## createRoom

`await createRoom(options = {}): Promise<RelayRoom>` creates and connects the host. `DEFAULT_RELAY` is `https://controller.plus`. A resolved promise means the host received `welcome`; it does not mean a phone has joined.

| Option | Default | Meaning |
| --- | --- | --- |
| `game: { id, name, requires? }` | Required | `id`: 1–48 lowercase letters, digits or dashes, starting with a letter/digit. `name`: nonempty string, at most 80 characters. |
| `maxPlayers: number` | 8 on the relay | Integer room seat limit, clamped by the service to 1–16. Disconnected reserved seats count. Local physical pads are not relay seats. |
| `relay: string` | `DEFAULT_RELAY` | HTTP(S) service origin. A supplied path is discarded. |
| `allowedOrigins: string[]` | Omitted | Up to eight allowed game-page origins in the request. Use exact origins, not paths. The creating browser page's accepted origin is also allowed. |
| `WebSocketImpl` | `globalThis.WebSocket` | Browser-compatible WebSocket constructor. |
| `fetchImpl` | `globalThis.fetch` | Fetch function used for room creation. |
| `peer: boolean` | `true` | Enable peer links if a peer constructor is available. |
| `RTCPeerConnectionImpl` | `globalThis.RTCPeerConnection` | Browser WebRTC, or the [Node peer adapter](sdk-node-peer.md). Node does not provide this by default. |
| `localControllers: boolean` | `true` | Watch browser physical gamepads after `gamepadconnected`. Has no effect without browser gamepad/event APIs. |
| `requires` | `game.requires`, then none | `{ controller: '1.0.0' }` or `{ controller: { ios: '1.0.0', android: '1.0.0' } }`. Gates installed phone apps; browser/unspecified apps are not gated. |

Missing game strings throw `TypeError`. Invalid requirements throw `ProtocolError` with `code: 'invalid_requires'`. Network, URL and WebSocket errors propagate. HTTP refusal throws `Error` with the service's `code`, such as `invalid_game`, `invalid_game_origins`, `rate_limited`, `room_limit` or `service_unavailable`. Handle the initial rejection separately from later `error` events. Initial connection has an eight-second timeout and is not automatically retried before the first successful welcome.

## Room properties

| Property | Value |
| --- | --- |
| `code: string` | Six-character room code. |
| `joinURL: string` | Player invite to show as a link or encode as a QR. Keep it out of permanent logs. |
| `qrSVG: string` | Ready-to-insert SVG encoding `joinURL`; this is a string property, not a function. |
| `expires: string` | ISO expiry time returned by the service. Rooms last at most six hours and can end earlier after inactivity. |
| `players: Map<string, Player>` | Current roster. Entries can remain with `connected: false` while their seats are reserved. |
| `session: Session` | Authenticated host connection; use for advanced catalog/feedback commands. Never expose its host token to players. |

A phone Player is `{ id, name, connected, link, source: 'phone', app: { platform, version } \| null, network, route, roundTripMs }`. `network` is `wifi`, `cellular`, `ethernet`, `other` or `unknown`. `route` starts as `relay`; `roundTripMs` starts as `null`. Local pad entries are `{ id: 'pad' + (index + 1), name, connected: true, source: 'gamepad', route: 'local', roundTripMs: null }`.

## Room methods

All player IDs below are strings from the roster. Do not identify players by display name.

| Full signature | Return and behavior |
| --- | --- |
| `on(type, callback): () => boolean \| undefined` | Register `(payload) => void`; call the returned function to unsubscribe. A Set deduplicates the same callback. There is no `off()` method. Listener exceptions propagate. |
| `view(playerId, view): void` | Send a JSON private view after `viewFor(view, player.app)` chooses a surface/fallback. Unknown player or invalid/oversize view is rejected asynchronously by the relay. Serialization can throw locally. |
| `state(value): void` | Replace public JSON state for every phone. Do not include roles, answers or tokens. Serialization can throw; size errors arrive asynchronously. |
| `feedback(playerId, cue): boolean` | Validate and send a built-in/catalog cue over a direct control channel when open, otherwise the host session. `true` means handed to transport, not played. Invalid cue IDs throw. No options argument here; use `session.playFeedback` for volume, pattern or request ID. |
| `rumble(playerId, { low = 0.6, high = 0.3, durationMs = 150 } = {}): boolean` | Low/high 0…1, duration 0…5000 ms. Throws on invalid values; uses the direct path when possible. |
| `remove(playerId): void` | Drop the peer link and ask the service to kick that seat. There is no `kick()` alias. A player must use a current invitation to join again. |
| `close(): void` | Stop polling/stale-input checks, close links and the host session. It does not delete the server room immediately. Call on game shutdown. |

`new RelayRoom(created, WebSocketImpl, { peer = true, RTCPeerConnectionImpl = globalThis.RTCPeerConnection, localControllers = true } = {})` is also exported. `created` must contain the creation response's `room`, `endpoint`, `hostToken`, `joinURL`, `qrSVG`, `expires`. The constructor starts local watchers but does not connect: `await room.session.connect()` is still required. Prefer `createRoom` unless your backend already created the room.

## Events

| Event | Exact callback payload |
| --- | --- |
| `join` | Phone: `{ playerId, name, source: 'phone', app, network }`. Local pad: `{ playerId, name, source: 'gamepad' }`. Fires again when a reserved phone seat reconnects. |
| `leave` | `{ playerId, name }`. Preserve game progress if a reconnect is allowed. |
| `input` | `{ playerId, name, input, source: 'phone' \| 'gamepad' }`. Full normalized state, including neutral release. |
| `event` | `{ playerId, event, payload }`. A custom phone action; verify the payload and game rules. |
| `route` | `{ playerId, route, roundTripMs }`. Phone routes: `lan`, `p2p`, `turn`, `relay`. Fired on route/peer RTT changes, not an initial snapshot. |
| `status` | `{ playerId, name, network }`. Network changes after the first roster entry. |
| `outdated` | `{ name, app, minimum, url }`. Refused old app; no player seat was assigned. |
| `connection` | Host connection metrics: `{ state, route, label, transport, secure, peer, pairing, roundTripMs, pingLabel }`; see [client](sdk-client.md#connection-metrics). This is the host socket, not each phone's route. |
| `error` | Protocol error frame `{ v: 1, type: 'error', code, message, ... }`, or retry error `{ message }`. |

State and welcome frames are available on `room.session`, not as room events. There is no room-level `state`, `view`, `disconnected` or `close` event. Subscribe to `room.session.on('disconnected', ...)` when needed. Event handlers added after connection do not replay earlier notifications; seed from `room.players` when attaching late.

## Limits and reconnects

Private views and public relay state each have a 14,000-byte JSON budget. Client messages have a 16,384-byte total budget and a 180-message/second ceiling per connection. Budget the command envelope too. Do not send views every render frame. See [surface limits](custom-controllers.md#limits) for controller-tree limits.

Phone seats are reserved for five minutes after disconnect. A valid rotating resume ticket keeps the `playerId`. The SDK releases stale held binary input after 1.2 seconds, checked every 250 ms. Clear your stored input on `leave` and host disconnect too. Initial input is event-driven, not a continuous 60 Hz stream. Host reconnection restores the roster; your game owns its simulation state and should resend current private views when needed.

## Room example

<!-- example: relay-room -->
```js
import { createRoom, movement, NEUTRAL } from '@controller-plus/sdk/relay-host';

export const room = await createRoom({
  game: { id: 'docs-room', name: 'Controller room' }, maxPlayers: 8,
  localControllers: false,
});
export const players = new Map();
room.on('join', ({ playerId, name }) => {
  const player = players.get(playerId) || { input: NEUTRAL(), score: 0 };
  Object.assign(player, { name, connected: true });
  players.set(playerId, player);
  room.view(playerId, { kind: 'gamepad', title: 'Controller room' });
  room.state({ players: [...players].map(([id, p]) => ({ id, name: p.name })) });
});
room.on('input', ({ playerId, input }) => {
  const p = players.get(playerId);
  if (p) { p.input = input; p.move = movement(input); }
});
room.on('leave', ({ playerId }) => {
  const p = players.get(playerId);
  if (p) { p.connected = false; p.input = NEUTRAL(); }
});
room.session.on('disconnected', () => {
  for (const p of players.values()) p.input = NEUTRAL();
});
room.on('error', ({ code, message }) => console.error(code || 'connection', message));
const unsubscribe = room.on('route', ({ playerId, route }) => console.log(playerId, route));
export function reward(playerId) {
  room.feedback(playerId, 'game.win');
  room.rumble(playerId, { low: 0.4, high: 0.2, durationMs: 120 });
}
export function kick(playerId) { room.remove(playerId); }
export function stop() { unsubscribe(); room.close(); }
// Render room.qrSVG, room.joinURL and room.code in your game's lobby.
```

`movement`, `BUTTONS` and `NEUTRAL` are re-exported from [controls](sdk-controls.md). Their signatures and behavior are unchanged.
