Controller+BETADownload

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 }:

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.