Bitskiff

Controller input

On this page

Send a game controller's state as bytes on a lossy channel. There is no controller layer and no schema to install: you pack the struct on the client and unpack it on the host.

Wire format

Pack a fixed struct with DataView and send it on a lossy channel. The host declares maxHz, which paces the sender, and latestOnly, which drops a stale packet; the client names the channel. Use a bitfield for the buttons and one int16 per axis, in an order you pick.

The client joins with your client key: see Custom client pages.

import { BitskiffClient } from '@bitskiff/client';

// buttons: a bitfield, button i is bit i.
function packPad(buttons: number, axes: number[]): Uint8Array {
  const bytes = new Uint8Array(4 + axes.length * 2);
  const v = new DataView(bytes.buffer);
  v.setUint32(0, buttons, true);
  axes.forEach((a, i) => v.setInt16(4 + i * 2, a, true));
  return bytes;
}

const client = new BitskiffClient({ key: 'bsk_pk_...' });   // your client key
const pad = client.channel('move');           // the host's row makes it lossy at 60 Hz; name, then join
await client.join('1CAbCdEfG1HPWtg');

const readPad = (): [number, number, number] => [0, 12000, -3000];   // your own source

const [buttons, x, y] = readPad();
pad.send(packPad(buttons, [x, y]));
import { BitskiffClient } from '@bitskiff/client';

// buttons: a bitfield, button i is bit i. axes: an array of int16, a fixed order you pick.
function packPad(buttons, axes) {
  const bytes = new Uint8Array(4 + axes.length * 2);
  const v = new DataView(bytes.buffer);
  v.setUint32(0, buttons, true);
  axes.forEach((a, i) => v.setInt16(4 + i * 2, a, true));
  return bytes;
}

const client = new BitskiffClient({ key: 'bsk_pk_...' });   // your client key
const pad = client.channel('move');           // the host's row makes it lossy at 60 Hz; name, then join
await client.join('1CAbCdEfG1HPWtg');

const readPad = () => [0, 12000, -3000];   // your own source: buttons, x, y

const [buttons, x, y] = readPad();
pad.send(packPad(buttons, [x, y]));

The host reads it back on the channel of the same name, the same way round:

import { BitskiffHost, type Message } from '@bitskiff/host';

const host = new BitskiffHost({ key: 'bsk_pk_...' });   // your host key

host.channel('move', { delivery: 'lossy', maxHz: 60, latestOnly: true }).on('message', (m: Message) => {
  if (typeof m.data === 'string') return;
  const v = new DataView(m.data.buffer, m.data.byteOffset, m.data.byteLength);
  console.log(m.client.id, v.getInt16(4, true), v.getInt16(6, true));  // buttons: v.getUint32(0, true)
});

const session = await host.open();                      // declare and listen, then open
import { BitskiffHost } from '@bitskiff/host';

const host = new BitskiffHost({ key: 'bsk_pk_...' });   // your host key

host.channel('move', { delivery: 'lossy', maxHz: 60, latestOnly: true }).on('message', (m) => {
  if (typeof m.data === 'string') return;
  const v = new DataView(m.data.buffer, m.data.byteOffset, m.data.byteLength);
  console.log(m.client.id, v.getInt16(4, true), v.getInt16(6, true));  // buttons: v.getUint32(0, true)
});

const session = await host.open();                      // declare and listen, then open

The channel name move is the sample client page's name for its joystick, and this struct is the one that page sends, so the host above reads the sample client page as it stands. On a page of your own the name is yours: pick one and spell it the same way on both halves.

Nothing in the middle reads those bytes. Put your own axis units, your own button order and your own timestamp in the struct. The service carries a string or bytes and looks at neither.

Send rate

Send the pad's whole state once per tick: your game loop, a requestAnimationFrame, or the controller's own poll. Do not send on every event the browser or the pad hands you. This carries on from the pad above.

const packCurrentPad = (): Uint8Array => new Uint8Array(8);   // whatever you read, packed

function tick(): void {
  pad.send(packCurrentPad());       // the whole state, once a frame
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
const packCurrentPad = () => new Uint8Array(8);   // whatever you read, packed

function tick() {
  pad.send(packCurrentPad());       // the whole state, once a frame
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

A stick fires far more often than a frame, so the extra sends buy nothing. Past maxHz, the SDK drops the send at the sender and returns { dropped: 'rate' }—the packet in hand, which may be the one that mattered. Sampling gives you one packet a frame, always carrying the newest state.

The latestOnly option on the host's declaration finishes the job at the far end. It is a receive rule: a packet that arrives behind one the same sender already delivered is dropped rather than handed over, so a reordered frame never moves a stick backwards. The same row's maxHz is what paces the client that sends.

Lost release packets

A lossy channel does not retransmit, so the packet that says "button up" or "stick back to centre" can be the one that is lost, and the host goes on showing the button held. Do both of these:

  • Keep sending for a few ticks after the last input. The neutral state then arrives even when one packet of it is lost. Every stream of level values on a lossy channel needs this, not just a pad.
  • Zero the pad when a client goes quiet. On the host, a clientChanged of disconnected or away, and clientLeft, all mean no more input is coming from that client, so clear its pad there instead of waiting for a packet that never arrives. Lifecycle has what each of those changes means.

Multiple layouts

Give each layout its own channel name. Two pads, or a pad and a wheel, are two channels. Do not put a tag byte in front of one struct. The channel name already says which kind of message this is.

The host declares each layout's channel, and a client names only the ones it handles. Changing a layout later means changing a name, rather than keeping a tag in step across both halves.

The sample client page

The sample client page's joystick sends the same struct. Its two axes go out as a packed struct on move, and the sample host reads them back and shows them. The sample client samples the stick sixty times a second and names move.

The sample host declares move as { delivery: 'lossy', maxHz: 60, latestOnly: true } and paints the latest position it has. The host's row sets the rate: without maxHz, lossy is paced at 30.

The sample host is the quickstart's host plus that read-back and the sample page's text line: press and readout come from the quickstart, move from this page.

Cost

A pad costs what its bytes cost. They are metered by direction like every other message, and the console shows the usage. To spend less, send a smaller struct or sample it at a lower rate.

Next steps