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. 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. 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 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
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. Their signatures and behavior are unchanged.