Controller+BETADownload

protocol reference

Validate JSON messages, invite links and input at a trust boundary. Import from @controller-plus/sdk/protocol, the package root, or /sdk/src/protocol.mjs. See Wire protocol v1 for the full connection sequence and wire for binary codecs.

Constants and errors

Export Value
VERSION 1, the JSON protocol version.
SDK_VERSION '0.1.0', the source SDK/browser version, separate from app versions.
MAX_FRAME_BYTES 16,384, UTF-8 bytes per client JSON frame.
CONTROL_INPUT_LIMITS Frozen { axisCount: 4, buttonCount: 16, motionMagnitude: 100 }.
APP_PLATFORMS Frozen ['ios', 'android', 'web', 'desktop'].
NETWORKS Frozen ['wifi', 'cellular', 'ethernet', 'other', 'unknown'].
new ProtocolError(code, message) Error with name: 'ProtocolError', supplied code and message.

Frame validation

parseFrame(raw: string | ArrayBuffer | ArrayBufferView): object decodes UTF-8, checks byte length, parses JSON and requires a non-array object, string type, and v === 1. Returns the parsed object, retaining other fields. It does not validate a specific message type.

validateHello(frame): frame requires type: 'hello' and role host, player or spectator. Normal joins require string room and token, each 1–256 characters. This checks shape, not whether credentials are authentic. It does not replace parseFrame's version check.

Optional hello field Validation
resume String at most 256 characters.
name String at most 24 characters.
capabilities At most 24 strings, each at most 48 characters.
app Supported platform and valid plain version; invalid values are deleted, not rejected.
network One of NETWORKS; invalid values are deleted.
pairing Player-only { id, secret }; ID [A-Za-z0-9_-]{8,64}, secret string 20–128 characters. Replaces room/token checks; only supporting desktop endpoints accept saved pairing.
remember { device, name?, platform? }; device uses the pairing ID syntax, name ≤48, platform ≤24.

validateEvent(frame): frame requires event name [a-z][a-z0-9_.-]{0,47} and a non-array object payload. Missing payload is set to {}. It mutates and returns the frame. It does not validate game rules, the frame's type, or its envelope version.

Input validation

applyInput(previous, frame): ControlState returns new arrays, using prior values (or neutral) for absent fields. It requires an integer seq from 0 to 2,147,483,647 but does not decide whether it is newer; the host owns ordering.

Field Validation
axes If present, exactly four finite numbers −1…1.
buttons If present, exactly sixteen finite numbers 0…1.
motion.gyro, motion.accel If present, three finite numbers −100…100 each.
motion.orientation If present, four finite numbers −1…1. Quaternion normalization is not checked.

Absent motion retains a clone of prior motion. Present motion replaces it with only recognized vectors; {} clears the old vectors. Unknown input fields are ignored. This is JSON delta behavior; every binary input frame is a full state.

Join URLs and manifests

joinURL(origin, room, token, { path = '/ws', page = '/join' } = {}): string creates an HTTP(S) URL with v, room, token in its fragment and a path field only for nondefault paths. path must match ^/(?!/)[A-Za-z0-9_/-]{1,127}$. The builder validates the scheme/path, but not the room/token syntax: use actual credentials from your host.

parseJoinURL(value): { v: 1, origin, endpoint, room, token } requires HTTP(S), no username/password, URL length ≤2048, room [A-Z0-9]{4,12}, token [A-Za-z0-9_-]{20,128}, and v=1. Duplicate v, room, token, path, tlsPort or pin is rejected. The returned ws/wss endpoint uses the same origin and validated path. Pin/TLS fields are not applied by this JavaScript parser; native pinned desktop pairing has its own implementation.

manifest(game, origin, room): ConnectionManifest returns version 1, session ID, game { id, name }, one developer-or-local WebSocket route at /ws with scope developer, priority 0 and codec json-v1. Policy is { allowOurRelay: false, desktopControl: false }. Required capability is input.touch; optional capabilities are input.gamepad, sensor.gyroscope, sensor.accelerometer. controls is copied from game; privateView defaults to 'none'. This helper has no custom path option.

App versions

Signature Return and defaults
parseVersion(value): number[] | null Three numeric components, each 1–4 digits. Rejects prefixes, suffixes and prereleases.
compareVersions(a, b): -1 | 0 | 1 Compare components numerically; invalid version treated as 0.0.0.
validateRequires(requires): object | null Null/undefined or empty requirements return null. Only controller allowed: a version string or a map of ios/android versions. Returns a copy; invalid input throws invalid_requires.
updateNeeded(app, requires): string | null Minimum version if an identified iOS/Android app is too old, otherwise null. Browser, missing app and other platforms pass. Invalid required version produces no gate.

Errors

Code Cause
frame_too_large, invalid_json, invalid_frame, version_mismatch Envelope cannot be parsed/accepted.
invalid_hello, invalid_name, invalid_capabilities, invalid_resume Invalid hello fields.
invalid_sequence, invalid_axes, invalid_buttons, invalid_motion Invalid input fields.
invalid_event Bad event name or non-object body.
invalid_endpoint, invalid_qr Invalid join address/path. URL construction can also throw a native TypeError.
invalid_requires Invalid minimum-app requirements.

This module emits no events and performs no network requests. Authentication/authorization and rate limits belong to the host.

Validate a handshake and input

import { joinURL, parseJoinURL, parseFrame, validateHello, validateEvent, applyInput,
  manifest, parseVersion, compareVersions, validateRequires, updateNeeded
} from '@controller-plus/sdk/protocol';
const url = joinURL('https://game.example', 'ABC123', 'example_player_token_123456');
const address = parseJoinURL(url);
const hello = validateHello(parseFrame(JSON.stringify({
  v: 1, type: 'hello', role: 'player', room: address.room, token: address.token, name: 'Ana',
})));
const input = applyInput(null, { seq: 0, axes: [1, 0, 0, 0] });
console.log(hello.name, input.axes, validateEvent({ event: 'vote', payload: { target: 'p2' } }));
console.log(manifest({ id: 'quiz', name: 'Quiz', controls: {} }, address.origin, address.room));
const requires = validateRequires({ controller: '1.0.0' });
console.log(parseVersion('1.0.0'), compareVersions('1.0.0', '0.9.0'));
console.log(updateNeeded({ platform: 'ios', version: '0.9.0' }, requires)); // 1.0.0