wire reference
Encode compact input for the hot path. Browser/source import: /sdk/src/wire.mjs. There is no package wire export; applications using Session or createRoom do not need to import this module. Negotiate wire: 1 in hello/welcome before sending binary input.
Constants and frame layout
WIRE_VERSION = 1; KIND is frozen { INPUT: 1, PING: 2, PONG: 3, RELAYED: 4 }. All multibyte numbers are little-endian. JSON envelopes remain version 1 and begin with {.
| Frame | Layout |
|---|---|
| Input | Byte 0: 1; bytes 1–2: uint16 sequence; byte 3: flags; bytes 4–5: button bitmask. Optional fields follow in flag order. |
| Flags | 1: four int8 sticks (×127); 2: two uint8 triggers at buttons 6/7 (×255); 4: three int16 gyro (×327); 8: three int16 accel (×327); 16: four int16 orientation (×32767). Other bits rejected. |
| Ping | Kind 2, uint16 ID, uint16 RTT; 65535 means unknown. Five bytes. |
| Pong | Kind 3, uint16 ID. Three bytes. |
| Relayed | Kind 4, uint8 ID byte length, UTF-8 player ID, inner frame. |
Input is 6–32 bytes: six for buttons only, ten with sticks, twelve with sticks and analog triggers. Non-trigger buttons use a ≥0.5 threshold. Omitted fields decode to zero/absent motion. Every frame is a full state. Sticks/triggers are quantized; do not compare received floats to arbitrary original decimals for exact equality.
Codec functions
Binary arguments accept ArrayBuffer and typed array views. Encoders return Uint8Array.
| Full signature | Return and errors |
|---|---|
new WireError(message) |
Error with name: 'WireError', code: 'invalid_binary'. |
isBinaryFrame(data): boolean |
False for strings; otherwise true if nonempty and first byte is not { (0x7b). This is classification, not validation. |
frameKind(data): number |
First byte or zero for empty bytes. |
encodeInput(input, seq): Uint8Array |
Missing axes/buttons become neutral; finite values quantized/clamped. Sequence wraps to uint16. Supply correctly sized motion vectors. |
decodeInput(data): { seq, input } |
Require kind 1, known flags and exact length; otherwise throw WireError. Does not range-check reserved int8 −128/int16 extremes from hand-crafted frames; use trusted encoder output or validate decoded ranges in your adapter. |
encodePing(id, roundTripMs = null) |
Wrap ID to uint16; round/clamp finite RTT to 0…65534; nonfinite/null becomes unknown. |
decodePing(data): { id, roundTripMs } |
Require five bytes and kind 2; unknown becomes null. |
encodePong(id) |
Wrap ID and encode three bytes. |
decodePong(data): { id } |
Require three bytes and kind 3. |
encodeRelayed(playerId, frame) |
ID must encode to 1–64 UTF-8 bytes, otherwise WireError. Inner bytes are not validated. |
decodeRelayed(data): { playerId, frame } |
Require nonempty ID and inner frame. Returns inner subarray without validating its type. Encoder enforces 64-byte ID cap; decoder does not. |
seqNewer(a, b): boolean |
True if uint16 distance from b to a is 1…32767. Handles wrap; equal and half-range distance are not newer. |
Malformed ping/pong/relayed frames throw WireError. Invalid JavaScript argument shapes can throw normal errors. Codec calls emit no events.
InputPacer
new InputPacer({ send, now = () => performance.now(), setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = timer => clearTimeout(timer) } = {})
Supply send(bytes, { repeat, edge }). repeat marks a copy/held refresh; edge marks a button-bitmask change. The callback's return value is ignored and exceptions propagate. Times are milliseconds. New state starts neutral with sequence zero.
| Method | Behavior |
|---|---|
configure({ minIntervalMs = 16, repeatsMs = [], refreshMs = 400, settleMs = 100 } = {}): void |
Replace analog pacing, repeat delays, held refresh and one-quantization-step jitter delay. Values are not validated. |
update(input): boolean |
Copy full state and optional motion; true when sent immediately, false when unchanged or scheduled. Button edges send immediately; analog changes may coalesce. |
flush(): void |
Send current state now with a new sequence. |
reset(): void |
Stop timers and forget sent-state comparison; does not reset sequence or current state. |
stop(): void |
Cancel all pacing timers. Does not send neutral input. |
RELEASE_COPIES_MS is frozen [30, 100, 250]. Becoming neutral schedules these copies on every route instead of ordinary repeat delays; newer input cancels their effect. Held nonneutral input refreshes at refreshMs; neutral input is quiet after release copies. Call stop during teardown. Session selects route-specific policies.
InputTrack
new InputTrack({ staleMs = 1200, now = () => performance.now() } = {}) holds public fields seq (initial null), input (neutral), at (initial −Infinity).
| Method | Return and behavior |
|---|---|
apply(seq, input): boolean |
Newer sequence replaces state and returns true. Same sequence refreshes liveness but returns false. Old sequence is ignored without refreshing. Stores the input object directly. |
set(input): void |
Replace input and refresh time without changing sequence. |
restart(): void |
Clear sequence only, for a resumed phone whose counter restarted. |
stale(): boolean |
True if nonneutral input has not refreshed for more than staleMs. Does not clear it; host must call set(NEUTRAL()) and update the game. |
Encode, pace and receive
import { NEUTRAL } from 'https://controller.plus/sdk/src/controls.mjs';
import { encodeInput, decodeInput, encodeRelayed, decodeRelayed, encodePing, decodePing,
encodePong, decodePong, seqNewer, isBinaryFrame, frameKind, InputPacer, InputTrack
} from 'https://controller.plus/sdk/src/wire.mjs';
const input = NEUTRAL(); input.buttons[0] = 1;
const bytes = encodeInput(input, 65535);
const wrapped = decodeRelayed(encodeRelayed('p1', bytes));
console.log(wrapped.playerId, decodeInput(wrapped.frame));
console.log(decodePing(encodePing(7, 12)), decodePong(encodePong(7)));
console.log(seqNewer(0, 65535), isBinaryFrame(bytes), frameKind(bytes));
let time = 0;
const track = new InputTrack({ now: () => time });
const pacer = new InputPacer({ send(frame) {
const { seq, input } = decodeInput(frame); track.apply(seq, input);
} });
pacer.configure({ minIntervalMs: 16, refreshMs: 400 });
pacer.update(input); pacer.flush();
time = 1300;
if (track.stale()) track.set(NEUTRAL());
track.restart(); pacer.reset(); pacer.stop();
console.log(track.input.buttons[0]); // 0