Bitskiff

Cat and mouse

On this page

Two clients join, each picks a role and confirms, and the game starts once both have confirmed different roles. One reliable channel carries the whole lobby conversation, so a pick and the answer to it stay in order; play rides a separate lossy channel with its own rate and drop rules.

Host

The host page needs no build step: the import map is what makes @bitskiff/host resolve in a page with no bundler, as No build step sets out. Save the markup as index.html and the block under it as host.js beside it, in a directory of its own: the client half below is a second page.

HTML
<script type="importmap">
{
  "imports": { "@bitskiff/host": "https://docs.dev.bitskiff.com/sdk/host@0.3.0.js" },
  "integrity": { "https://docs.dev.bitskiff.com/sdk/host@0.3.0.js": "sha384-t4tevb6IaP3UssGRlkmHoQscYfON1BN2JyGY3zsoEGm3KmG2W3Mbe4icXcXQ7uGA" }
}
</script>

<script type="module" src="./host.js"></script>
import { BitskiffHost, type Client, type ClientLeft, type Message } from '@bitskiff/host';

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

// The host declares every channel before it opens.
// client -> host: 'cat' | 'mouse' | 'confirm'
// host -> client: 'pick a role' | 'waiting' | 'you are the cat' | 'you are the mouse' | 'go'
const lobby = host.channel('lobby');
const move = host.channel('move', { delivery: 'lossy', maxHz: 60, latestOnly: true });

const session = await host.open({ maxClients: 2 });

console.log(session.joinCode.url);  // open this in a second tab to bring a client in

// Your game. Two sprites that take a steering packet, and a way to stop.
const cat = { steer: (data: string | Uint8Array): void => console.log('cat', data) };
const mouse = { steer: (data: string | Uint8Array): void => console.log('mouse', data) };
const stopGame = (why: string): void => console.log('stopped:', why);

interface Pick {
  role?: 'cat' | 'mouse';
  confirmed: boolean;
}

const picks = new Map<Client, Pick>();   // one entry per client, the Client as the key

let catClient: Client | undefined;  // the Client steering the cat, once both have confirmed
let mouseClient: Client | undefined;
let started = false;
let releaseSteering: (() => void) | undefined;   // drops both `{ from }` handlers when the game stops

session.on('clientJoined', (client: Client) => {
  picks.set(client, { confirmed: false });
  lobby.send(client, 'pick a role');
});

session.on('clientLeft', (e: ClientLeft) => {
  picks.delete(e.client);
  if (e.client !== catClient && e.client !== mouseClient) return;
  releaseSteering?.();              // the two `{ from }` handlers go with the game
  releaseSteering = undefined;
  started = false;
  stopGame('a client left');
});

lobby.on('message', (m: Message) => {
  const pick = picks.get(m.client);
  if (pick === undefined) return;
  if (m.data === 'cat' || m.data === 'mouse') {
    pick.role = m.data;
    pick.confirmed = false;
  }
  if (m.data === 'confirm' && pick.role !== undefined) pick.confirmed = true;
  tryStart();
});

function tryStart(): void {
  if (started) return;              // one game, one pair of steering handlers
  const [a, b] = [...picks.entries()];
  if (a === undefined || b === undefined) return;

  const ready = a[1].confirmed && b[1].confirmed && a[1].role !== b[1].role;
  if (!ready) {
    lobby.broadcast('waiting');
    return;
  }

  catClient = a[1].role === 'cat' ? a[0] : b[0];
  mouseClient = a[1].role === 'cat' ? b[0] : a[0];

  started = true;
  const offCat = move.on('message', (m: Message) => cat.steer(m.data), { from: catClient });
  const offMouse = move.on('message', (m: Message) => mouse.steer(m.data), { from: mouseClient });
  releaseSteering = () => {
    offCat();
    offMouse();
  };                                // each `on` hands back its `Unsubscribe`

  lobby.send(catClient, 'you are the cat');
  lobby.send(mouseClient, 'you are the mouse');
  lobby.broadcast('go');
}
import { BitskiffHost } from '@bitskiff/host';

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

// The host declares every channel before it opens.
// client -> host: 'cat' | 'mouse' | 'confirm'
// host -> client: 'pick a role' | 'waiting' | 'you are the cat' | 'you are the mouse' | 'go'
const lobby = host.channel('lobby');
const move = host.channel('move', { delivery: 'lossy', maxHz: 60, latestOnly: true });

const session = await host.open({ maxClients: 2 });

console.log(session.joinCode.url);  // open this in a second tab to bring a client in

// Your game. Two sprites that take a steering packet (a string or bytes), and a way to stop.
const cat = { steer: (data) => console.log('cat', data) };
const mouse = { steer: (data) => console.log('mouse', data) };
const stopGame = (why) => console.log('stopped:', why);

// One entry per client: the Client is the key, { role: 'cat' | 'mouse', confirmed } the value.
const picks = new Map();

let catClient;                      // the Client steering the cat, once both have confirmed
let mouseClient;
let started = false;
let releaseSteering;                // drops both `{ from }` handlers when the game stops

session.on('clientJoined', (client) => {
  picks.set(client, { confirmed: false });
  lobby.send(client, 'pick a role');
});

session.on('clientLeft', (e) => {
  picks.delete(e.client);
  if (e.client !== catClient && e.client !== mouseClient) return;
  releaseSteering?.();              // the two `{ from }` handlers go with the game
  releaseSteering = undefined;
  started = false;
  stopGame('a client left');
});

lobby.on('message', (m) => {
  const pick = picks.get(m.client);
  if (pick === undefined) return;
  if (m.data === 'cat' || m.data === 'mouse') {
    pick.role = m.data;
    pick.confirmed = false;
  }
  if (m.data === 'confirm' && pick.role !== undefined) pick.confirmed = true;
  tryStart();
});

function tryStart() {
  if (started) return;              // one game, one pair of steering handlers
  const [a, b] = [...picks.entries()];
  if (a === undefined || b === undefined) return;

  const ready = a[1].confirmed && b[1].confirmed && a[1].role !== b[1].role;
  if (!ready) {
    lobby.broadcast('waiting');
    return;
  }

  catClient = a[1].role === 'cat' ? a[0] : b[0];
  mouseClient = a[1].role === 'cat' ? b[0] : a[0];

  started = true;
  const offCat = move.on('message', (m) => cat.steer(m.data), { from: catClient });
  const offMouse = move.on('message', (m) => mouse.steer(m.data), { from: mouseClient });
  releaseSteering = () => {
    offCat();
    offMouse();
  };                                // each `on` hands back its `Unsubscribe`

  lobby.send(catClient, 'you are the cat');
  lobby.send(mouseClient, 'you are the mouse');
  lobby.broadcast('go');
}

Client

The same page for both clients. Save the markup as index.html and the script beside it as client.js.

HTML
<!-- the whole import map of No build step goes here -->
<button id="cat">cat</button>
<button id="mouse">mouse</button>
<button id="confirm">confirm</button>
<p id="status"></p>
<script type="module" src="./client.js"></script>

The import map is the one of No build step, which carries @bitskiff/client.

Give this page a client key before you load it. Set the project's join URL to this page and a client key for its origin is created with it (The client key), or create one yourself (Client keys).

import { BitskiffClient, isBitskiffError, type Message } from '@bitskiff/client';

const client = new BitskiffClient({ key: 'bsk_pk_...' });   // your client key

// From the page above: the code your link carried, three buttons, a line of text, a stick.
const code = location.hash.slice(1);
const catButton = document.getElementById('cat');
const mouseButton = document.getElementById('mouse');
const confirmButton = document.getElementById('confirm');
const status = document.getElementById('status');
if (!catButton || !mouseButton || !confirmButton || !status) throw new Error('missing element');

const joystick = { onmove: (x: number, y: number): void => console.log(x, y) };   // your stick
const encode = (x: number, y: number): Uint8Array => new Uint8Array(new Float32Array([x, y]).buffer);

const lobby = client.channel('lobby');
const move = client.channel('move');           // the host's row: lossy, 60 a second

catButton.onclick = () => lobby.send('cat');
mouseButton.onclick = () => lobby.send('mouse');
confirmButton.onclick = () => lobby.send('confirm');

lobby.on('message', (m: Message) => {
  if (typeof m.data !== 'string') return;   // the lobby speaks strings; bytes are somebody else's
  status.textContent = m.data;
  if (m.data === 'go') joystick.onmove = (x, y) => move.send(encode(x, y));
});

try {
  await client.join(code);   // named and listening first, so 'pick a role' lands
} catch (error) {
  if (!isBitskiffError(error)) throw error;
  // A refused join leaves the handle `ended`. Try again on a new `BitskiffClient`.
  status.textContent = `could not join: ${error.code}`;
}
import { BitskiffClient, isBitskiffError } from '@bitskiff/client';

const client = new BitskiffClient({ key: 'bsk_pk_...' });   // your client key

// From the page above: the code your link carried, three buttons, a line of text, a stick.
const code = location.hash.slice(1);
const catButton = document.getElementById('cat');
const mouseButton = document.getElementById('mouse');
const confirmButton = document.getElementById('confirm');
const status = document.getElementById('status');
if (!catButton || !mouseButton || !confirmButton || !status) throw new Error('missing element');

const joystick = { onmove: (x, y) => console.log(x, y) };   // your stick; x and y are numbers
const encode = (x, y) => new Uint8Array(new Float32Array([x, y]).buffer);

const lobby = client.channel('lobby');
const move = client.channel('move');           // the host's row: lossy, 60 a second

catButton.onclick = () => lobby.send('cat');
mouseButton.onclick = () => lobby.send('mouse');
confirmButton.onclick = () => lobby.send('confirm');

lobby.on('message', (m) => {
  if (typeof m.data !== 'string') return;   // the lobby speaks strings; bytes are somebody else's
  status.textContent = m.data;
  if (m.data === 'go') joystick.onmove = (x, y) => move.send(encode(x, y));
});

try {
  await client.join(code);   // named and listening first, so 'pick a role' lands
} catch (error) {
  if (!isBitskiffError(error)) throw error;
  // A refused join leaves the handle `ended`. Try again on a new `BitskiffClient`.
  status.textContent = `could not join: ${error.code}`;
}

Design notes

  • The roles are two variables holding real client objects. The per-client listeners are declared with { from }, not with an if on an id inside a shared handler, and clientLeft compares by identity on e.client.
  • The per-client listeners are bound once, behind started, and released with the Unsubscribe each on returns when the game ends. Binding them there is what keeps a handler out of a message handler, where it would be bound again on every message.
  • Each lifecycle event has its own shape: clientLeft hands { client, reason, at, stopPropagation() } and clientChanged hands { client, change, previous, at, stopPropagation() }. Both go to the client-scoped handlers first and then to the session-wide ones, the way a channel hands one Message to both.
  • The page names both channels and attaches both handlers before join. A name held before the join rides the hello, and the handler has to be on the channel by then too: the host's first lobby.send(client, 'pick a role') goes out from inside clientJoined, which runs while the page's join is still resolving. A message that arrives before your handler is attached is not replayed, and a unicast on a name the page never named is refused { dropped: 'notListening' }. Nothing is buffered either way.
  • Every reply goes back through lobby.send(m.client, ...); no id string changes hands.
  • One channel for the lobby because a pick and its answer must stay in order, and ordering never crosses channels. A second channel for play because it needs a different delivery contract: lossy, rate-capped, latest frame wins.
  • Both options are on the host's row, the one definition of the channel. maxHz paces the client that sends; a lossy channel without it is paced at 30 a second. The latestOnly option is the receiver's drop rule, which is exactly what a steering channel wants. Channels has both.
  • Nothing in the game knows what a session or a channel is on the wire.

Next steps