client reference
Use Session for a custom phone interface or an engine connection with host authority. Import Session and PACING from @controller-plus/sdk, or import /sdk/src/client.mjs in a browser. There is no @controller-plus/sdk/client subpath.
Session constructor
new Session(options) creates a disconnected connection. Session.fromQR(qr, options = {}): Session parses a complete player join URL, then applies your option overrides. Invalid QR input throws ProtocolError('invalid_qr', message).
| Option | Default | Meaning |
|---|---|---|
endpoint, room, token |
Required | WebSocket URL and credentials. Prefer fromQR for player invites. |
role |
'player' |
player, host, or spectator; endpoint support differs. ControllerHost accepts player/host only. |
name |
'Player' |
Display name, at most 24 characters at the protocol boundary. |
capabilities |
['input.touch'] |
Up to 24 strings of at most 48 characters each. Advertise only implemented features. |
resume |
null |
Last rotating resume ticket for this seat. Store the new value after every welcome. |
WebSocketImpl |
globalThis.WebSocket |
Constructor implementing browser WebSocket events. |
reconnect |
true |
Retry after a previously established connection drops. Does not retry the first failed join. |
pairingMethod |
'link' |
Informational connection-metrics label; use 'qr' for a scan. |
wire |
true |
Request wire format 1; use JSON input if the server does not negotiate it. |
peer |
true |
Offer WebRTC support when the constructor below exists. |
RTCPeerConnectionImpl |
globalThis.RTCPeerConnection |
Peer implementation. |
app |
null |
{ platform, version }; valid platforms are ios, android, web, desktop. Version is plain major.minor.patch. |
Connection and input methods
| Full signature | Return, defaults and errors |
|---|---|
connect(): Promise<WelcomeFrame> |
Open a new socket and authenticate. Eight-second timeout. Rejects on initial protocol/network failure. Resets input on welcome. Calling again replaces the socket. |
on(event, callback): () => boolean | undefined |
Subscribe; returned function unsubscribes. No off() alias. |
emit(event, value): void |
Invoke local listeners synchronously. Does not send a message; callback errors propagate. |
send(frame): boolean |
Send JSON { v: 1, ...frame } if ready; otherwise false. Does not validate or queue frames. JSON serialization may throw. |
sendBinary(bytes): boolean |
Send bytes on the socket if ready; otherwise false. Does not negotiate or validate the bytes. |
input(input, force = false): void |
Quantize a complete axes/buttons state, cache it, and pass it to the input pacer when ready. force flushes immediately. Invalid array shapes can throw; do not use this as a validator. |
neutralize(): void |
Player: send a forced neutral input. Host: clear only local lastState. |
event(event, payload = {}): void |
Send a player event. Name must match [a-z][a-z0-9_.-]{0,47}; payload must be an object. Server validates it. |
command(command, payload = {}): void |
Send a host command. The server enforces the role. |
ping(): void |
Probe current path if ready. Automatic probes already run. |
setNetwork(network): void |
Store network and send a status change only if the server advertises status: 1. Values: wifi, cellular, ethernet, other, unknown. |
reportSettings(rows): void |
Store and, when settings: 1 is advertised, send controller-setting rows; repeat after reconnect. Rows are { id, title, value, options: [[value, title], ...], help? }. Incoming settings.set must be handled by your UI. This does not grant desktop-control authority. |
pacingFor(path = this.inputPath, route = this.peer?.route): object |
Get current pacing policy; only path: 'peer' uses peer pacing. |
updateConnection(change): void |
Recompute and emit metrics using supplied metric fields; normally managed internally. |
close(): void |
Neutralize, stop retries/pacing/pings, close peer/socket, set ready = false. |
Useful properties: ready, playerId, resume, lastState, metrics, endpoint, room, role, name, capabilities, app, network, route, tier, inputPath (socket or peer), wire (negotiated boolean), pairingCode when provided by the endpoint. socket and peer are transport objects; do not rely on them for game state.
After an established connection fails, retries wait 400, 800, 1600, 3200, then at most 5000 ms. invalid_resume, unauthorized, room_expired, kicked, room_full, app_update_required, or close code 4001 stop retries. A new Session is the cleanest way to start a fresh invitation after one of these failures. A closed, non-retrying session may still report reconnecting; inspect the error and reconnect flag too.
Feedback methods
These require host authority. All return a boolean send result and throw on invalid feedback/catalog data before sending.
| Signature | Behavior |
|---|---|
registerFeedbackCatalog(entries): boolean |
Normalize against the session endpoint origin and send a catalog command. |
playFeedback(playerId, cue, options = {}): boolean |
Options: id?, volume = 1, pattern?. |
rumble(playerId, options): boolean |
Required low, high, durationMs; optional id. Unlike RelayRoom.rumble, no strength/duration defaults. |
stopFeedback(playerId, channel = 'all'): boolean |
all, sound, or vibration. |
See feedback validation and budgets.
Events and frames
A frame's v is 1. Except for welcome and internally handled ping/peer messages, incoming JSON is emitted both under its type and under message. Extra endpoint-specific fields are preserved. Session parses envelopes, not every payload schema.
| Event | Payload |
|---|---|
welcome |
{ v, type: 'welcome', role, room, playerId?, resume?, pairingCode?, manifest?, tier?, wire?, peer?, connection?, status?, settings? }. peer may contain { ice }; connection may contain { route }. Host welcomes omit player fields. |
state |
{ v, type: 'state', room: { id, name, players: [{ id, name, connected, ... }] }, game, tier, ... }. Relay roster entries can also carry app, network, link. |
view |
{ v, type: 'view', view }, for this phone. |
feedback-catalog |
{ v, type: 'feedback-catalog', entries }. |
feedback |
{ v, type: 'feedback', ...validatedFeedback }. A peer feedback event does not also emit message. |
device-input |
Host: { v, type: 'device-input', playerId, name, input }. Binary relay input instead arrives through binary. |
device-left |
Host: { v, type: 'device-left', playerId }. |
player-event |
Host: { v, type: 'player-event', playerId, event, payload }. |
update-required |
Player: { v, type: 'update-required', minimum, current, platform, game, url }. Sent before refusal when an app must update. |
player-outdated |
Relay host: { v, type: 'player-outdated', name, app, minimum, url }. |
signal |
Host signaling frame: { v, type: 'signal', playerId, link, data }. Player signaling is handled internally. |
settings |
{ v, type: 'settings', set: { [id]: value } } from a supporting desktop host. |
error |
{ v, type: 'error', code, message, ... }, or reconnect failure { message }. |
disconnected |
{ code }, the WebSocket close code. |
connection, route |
Connection metrics below. route fires when the input path changes. |
binary |
{ bytes: Uint8Array, path: 'socket' | 'peer' }, excluding handled ping/pong. |
message |
The unmodified dispatched JSON frame; no separate envelope. |
Other JSON types, such as haptic and capability-request, are forwarded under their type and message; the UI owns interpretation and permission handling. Welcome is emitted before connect() resolves. Register listeners before connecting.
Connection metrics
{ state, route, label, transport, secure, peer, pairing, roundTripMs, pingLabel }:
state:disconnected,connecting,connected,reconnecting,degradedormoved.route:local,developer,peer,turnorrelay; labels are human-readable. Room per-player events instead uselan/p2pfor the first two peer routes.transport:WebSocket,Secure WebSocketorWebRTC;secureis a boolean.peer: endpoint host including port, not a player's identity.pairing: the pairing-method string.roundTripMs: smoothed RTT ornull;pingLabel:—,<1 msor rounded milliseconds. No ping value promises total game latency.
Pacing and limits
PACING is the exported policy table. All durations are milliseconds. Button edges go immediately; intervals apply to analog changes.
| Key | minIntervalMs |
repeatsMs |
refreshMs |
pingMs |
settleMs |
reliableEdges |
|---|---|---|---|---|---|---|
socket |
16 | [] |
400 | 2000 | 50 | absent |
lan |
8 | [30] |
400 | 2000 | 30 | absent |
p2p |
8 | [40] |
400 | 2000 | 30 | absent |
turn |
40 | [] |
450 | 4000 | absent; pacer uses 100 | true |
turnPro |
16 | [] |
400 | 2000 | absent; pacer uses 100 | true |
Release copies use the wire policy. Input is bounded to four axes and sixteen buttons. JSON client frames must fit 16,384 bytes, including event payloads. The client accepts incoming JSON text up to MAX_FRAME_BYTES * 16 characters, but host-side view/state limits are smaller. There is no arbitrary media channel.
Custom phone example
This complete page takes a player invitation in the join query parameter. If the custom UI is hosted on a different origin, include that exact origin in the room creator's allowedOrigins (or the direct host's allowedOrigins). The button sends held input; releasing, cancellation, blur and backgrounding clear it. Add your own visible status styling.
<!doctype html>
<meta charset="utf-8">
<title>Custom controller</title>
<p id="status">Connecting…</p><button id="fire" disabled>Fire</button>
<script type="module">
import { Session } from 'https://controller.plus/sdk/src/client.mjs';
import { NEUTRAL } from 'https://controller.plus/sdk/src/controls.mjs';
const status = document.querySelector('#status');
const fire = document.querySelector('#fire');
const invite = new URL(location.href).searchParams.get('join');
let session;
try {
session = Session.fromQR(invite, { name: 'Pilot', peer: true });
session.on('connection', m => { status.textContent = m.state + ' · ' + m.pingLabel; });
session.on('error', e => { status.textContent = e.message; });
session.on('disconnected', () => { fire.disabled = true; });
session.on('welcome', () => { fire.disabled = false; });
session.on('view', ({ view }) => { document.title = view.title || 'Custom controller'; });
const release = () => session.neutralize();
fire.onpointerdown = e => {
fire.setPointerCapture(e.pointerId);
const input = NEUTRAL(); input.buttons[0] = 1; session.input(input);
};
fire.onpointerup = release; fire.onpointercancel = release;
fire.onlostpointercapture = release;
addEventListener('blur', release);
document.addEventListener('visibilitychange', () => { if (document.hidden) release(); });
addEventListener('pagehide', () => session.close());
await session.connect();
} catch (error) { status.textContent = 'Could not join: ' + error.message; session?.close(); }
</script>
This UI intentionally supplies only a fire button. A full replacement controller must implement its advertised views and feedback, retain the latest resume ticket if it survives page reload, validate host requests, and ask for sensor permissions from a user gesture. Prefer the included controller when you need all built-in parts.