# 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](sdk-feedback.md).

## 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`, `degraded` or `moved`.
- `route`: `local`, `developer`, `peer`, `turn` or `relay`; labels are human-readable. Room per-player events instead use `lan`/`p2p` for the first two peer routes.
- `transport`: `WebSocket`, `Secure WebSocket` or `WebRTC`; `secure` is a boolean.
- `peer`: endpoint host including port, not a player's identity. `pairing`: the pairing-method string.
- `roundTripMs`: smoothed RTT or `null`; `pingLabel`: `—`, `<1 ms` or 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](sdk-wire.md#inputpacer). 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.

<!-- example: custom-phone -->
```html
<!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.
