Cat and mouse
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.
<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.
<!-- 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 anifon an id inside a shared handler, andclientLeftcompares by identity one.client. - The per-client listeners are bound once, behind
started, and released with theUnsubscribeeachonreturns 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:
clientLefthands{ client, reason, at, stopPropagation() }andclientChangedhands{ 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 oneMessageto 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 firstlobby.send(client, 'pick a role')goes out from insideclientJoined, which runs while the page'sjoinis 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.
maxHzpaces the client that sends; a lossy channel without it is paced at 30 a second. ThelatestOnlyoption 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.