Bitskiff

A queue and four slots

On this page

A venue host with four playing slots and any number of clients waiting. Clients join at any time and take a place in line. When a round ends, the next four in line take the slots, in the order they joined. The host owns a mapping that says which client is in which slot. A client is never a slot itself.

Host

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

HTML
<!doctype html>
<meta charset="utf-8">
<button id="start">start</button>
<button id="end">end</button>

<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, formatDuration, type Client, type ClientChanged, type ClientLeft, type Message } from '@bitskiff/host';

// Your game. It takes a slot index and a steering packet, and knows nothing about clients.
const game = {
  start: (players: number): void => console.log('start', players),
  steer: (slot: number, data: string | Uint8Array): void => console.log(slot, data),
  vacate: (slot: number): void => console.log('vacate', slot),
};
// Your host page: the two controls that run a round.
const startButton = document.getElementById('start');
const endButton = document.getElementById('end');
if (!startButton || !endButton) throw new Error('missing element');

const SLOTS = 4;
const WAITING_AWAY_MS = 5 * 60_000;   // a waiting client may be away this long and keep its place
const PLAYING_AWAY_MS = 10_000;       // a playing client is swapped out after this

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

// The host declares every channel before it opens.
const lobby = host.channel('lobby');   // host -> client: 'waiting 3' | 'slot 2' | 'round over'
const move = host.channel('move', { delivery: 'lossy', maxHz: 60, latestOnly: true });

// The platform's own background budget is 30 seconds and it ends a client before any host timer
// could; a host that keeps a waiting client longer than that raises it at `open`.
const session = await host.open({
  // The project's own `concurrentClients` (40 by default) refuses before this number does; raise
  // it to match in the console, or the 41st client is refused `concurrency_limit`.
  maxClients: 100,
  // A minute past WAITING_AWAY_MS. `formatDuration` writes the string the option takes, so the
  // budget follows the waiting window whenever that number changes.
  backgroundBudget: formatDuration(WAITING_AWAY_MS + 60_000),
});

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

const line: Client[] = [];                                     // the clients waiting, in join order
const slots: (Client | undefined)[] = Array(SLOTS).fill(undefined);
const awayTimers = new Map<Client, ReturnType<typeof setTimeout>>();

session.on('clientJoined', (client: Client) => {
  line.push(client);
  tellPlace(client);

  client.on('change', (e: ClientChanged) => {
    if (e.change === 'away') startAwayTimer(client);
    if (e.change === 'present') clearAwayTimer(client);
  });
  client.on('left', (e: ClientLeft) => remove(e.client));   // one event object, the session's own
});

move.on('message', (m: Message) => {
  const slot = slots.indexOf(m.client);
  if (slot >= 0) game.steer(slot, m.data);                                // slot index, not client
});

startButton.onclick = () => startRound();
endButton.onclick = () => endRound();

function startRound(): void {
  for (let i = 0; i < SLOTS; i++) {
    const next = nextPresent();                 // an away client keeps its place in line
    if (next === undefined) break;
    slots[i] = next;
    lobby.send(next, `slot ${i + 1}`);
  }
  line.forEach(tellPlace);
  game.start(slots.filter((client) => client !== undefined).length);
}

function endRound(): void {
  for (const client of slots) {
    if (client === undefined) continue;
    lobby.send(client, 'round over');
    line.push(client);
  }
  slots.fill(undefined);
  line.forEach(tellPlace);
}

function remove(client: Client): void {
  clearAwayTimer(client);
  const slot = slots.indexOf(client);
  if (slot >= 0) {
    slots[slot] = undefined;
    game.vacate(slot);
    backfill(slot);
  }
  const at = line.indexOf(client);
  if (at >= 0) line.splice(at, 1);
  line.forEach(tellPlace);
}

function backfill(slot: number): void {
  const next = nextPresent();
  if (next === undefined) return;
  slots[slot] = next;
  lobby.send(next, `slot ${slot + 1}`);
}

// The first client in line whose page is in front of somebody. An away client keeps its place
// rather than being handed a slot it cannot play: its waiting timer is still running, and five
// minutes later it would leave the slot occupied by nobody.
function nextPresent(): Client | undefined {
  const at = line.findIndex((client) => client.state.presence === 'present');
  return at < 0 ? undefined : line.splice(at, 1)[0];
}

function startAwayTimer(client: Client): void {
  // The wait is the one for where the client is now; whether it is playing is read again when
  // the timer fires, because a waiting client can be handed a slot while the timer runs.
  const ms = slots.includes(client) ? PLAYING_AWAY_MS : WAITING_AWAY_MS;
  awayTimers.set(
    client,
    setTimeout(() => {
      if (slots.includes(client)) {
        remove(client);             // back of the line, not booted
        line.push(client);
        tellPlace(client);
        startAwayTimer(client);     // still away: the waiting timer boots it `idle`
        return;
      }
      // A client that left while the timer ran rejects with `not_found`: the boot is a promise.
      void client.boot('idle').catch(() => remove(client));
    }, ms),
  );
}

function clearAwayTimer(client: Client): void {
  const timer = awayTimers.get(client);
  if (timer !== undefined) clearTimeout(timer);
  awayTimers.delete(client);
}

// Only the front of the line hears its place, so the line moving costs ten sends and not a hundred.
const TOLD = 10;

function tellPlace(client: Client): void {
  const at = line.indexOf(client);
  if (at >= 0 && at < TOLD) lobby.send(client, `waiting ${at + 1}`);
}
import { BitskiffHost, formatDuration } from '@bitskiff/host';

// Your game. It takes a slot index and a steering packet, and knows nothing about clients.
const game = {
  start: (players) => console.log('start', players),
  steer: (slot, data) => console.log(slot, data),   // slot index, and a string or bytes
  vacate: (slot) => console.log('vacate', slot),
};
// Your host page: the two controls that run a round.
const startButton = document.getElementById('start');
const endButton = document.getElementById('end');
if (!startButton || !endButton) throw new Error('missing element');

const SLOTS = 4;
const WAITING_AWAY_MS = 5 * 60_000;   // a waiting client may be away this long and keep its place
const PLAYING_AWAY_MS = 10_000;       // a playing client is swapped out after this

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

// The host declares every channel before it opens.
const lobby = host.channel('lobby');   // host -> client: 'waiting 3' | 'slot 2' | 'round over'
const move = host.channel('move', { delivery: 'lossy', maxHz: 60, latestOnly: true });

// The platform's own background budget is 30 seconds and it ends a client before any host timer
// could; a host that keeps a waiting client longer than that raises it at `open`.
const session = await host.open({
  // The project's own `concurrentClients` (40 by default) refuses before this number does; raise
  // it to match in the console, or the 41st client is refused `concurrency_limit`.
  maxClients: 100,
  // A minute past WAITING_AWAY_MS. `formatDuration` writes the string the option takes, so the
  // budget follows the waiting window whenever that number changes.
  backgroundBudget: formatDuration(WAITING_AWAY_MS + 60_000),
});

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

const line = [];                                     // the clients waiting, in join order
const slots = Array(SLOTS).fill(undefined);          // slot index -> the client in it, or undefined
const awayTimers = new Map();                        // Client -> the setTimeout handle for it

session.on('clientJoined', (client) => {
  line.push(client);
  tellPlace(client);

  client.on('change', (e) => {
    if (e.change === 'away') startAwayTimer(client);
    if (e.change === 'present') clearAwayTimer(client);
  });
  client.on('left', (e) => remove(e.client));   // one event object, the same one the session gets
});

move.on('message', (m) => {
  const slot = slots.indexOf(m.client);
  if (slot >= 0) game.steer(slot, m.data);                                // slot index, not client
});

startButton.onclick = () => startRound();
endButton.onclick = () => endRound();

function startRound() {
  for (let i = 0; i < SLOTS; i++) {
    const next = nextPresent();                 // an away client keeps its place in line
    if (next === undefined) break;
    slots[i] = next;
    lobby.send(next, `slot ${i + 1}`);
  }
  line.forEach(tellPlace);
  game.start(slots.filter((client) => client !== undefined).length);
}

function endRound() {
  for (const client of slots) {
    if (client === undefined) continue;
    lobby.send(client, 'round over');
    line.push(client);
  }
  slots.fill(undefined);
  line.forEach(tellPlace);
}

function remove(client) {
  clearAwayTimer(client);
  const slot = slots.indexOf(client);
  if (slot >= 0) {
    slots[slot] = undefined;
    game.vacate(slot);
    backfill(slot);
  }
  const at = line.indexOf(client);
  if (at >= 0) line.splice(at, 1);
  line.forEach(tellPlace);
}

function backfill(slot) {
  const next = nextPresent();
  if (next === undefined) return;
  slots[slot] = next;
  lobby.send(next, `slot ${slot + 1}`);
}

// The first client in line whose page is in front of somebody. An away client keeps its place
// rather than being handed a slot it cannot play: its waiting timer is still running, and five
// minutes later it would leave the slot occupied by nobody.
function nextPresent() {                        // a client, or undefined when nobody is present
  const at = line.findIndex((client) => client.state.presence === 'present');
  return at < 0 ? undefined : line.splice(at, 1)[0];
}

function startAwayTimer(client) {
  // The wait is the one for where the client is now; whether it is playing is read again when
  // the timer fires, because a waiting client can be handed a slot while the timer runs.
  const ms = slots.includes(client) ? PLAYING_AWAY_MS : WAITING_AWAY_MS;
  awayTimers.set(
    client,
    setTimeout(() => {
      if (slots.includes(client)) {
        remove(client);             // back of the line, not booted
        line.push(client);
        tellPlace(client);
        startAwayTimer(client);     // still away: the waiting timer boots it `idle`
        return;
      }
      // A client that left while the timer ran rejects with `not_found`: the boot is a promise.
      void client.boot('idle').catch(() => remove(client));
    }, ms),
  );
}

function clearAwayTimer(client) {
  const timer = awayTimers.get(client);
  if (timer !== undefined) clearTimeout(timer);
  awayTimers.delete(client);
}

// Only the front of the line hears its place, so the line moving costs ten sends and not a hundred.
const TOLD = 10;

function tellPlace(client) {
  const at = line.indexOf(client);
  if (at >= 0 && at < TOLD) lobby.send(client, `waiting ${at + 1}`);
}

Client

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 -->
<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, a line of text, a stick.
const code = location.hash.slice(1);
const status = document.getElementById('status');
if (!status) throw new Error('missing element');
const joystick = { enabled: false, onmove: (x: number, y: number): void => console.log(x, y) };
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

lobby.on('message', (m: Message) => {
  if (typeof m.data !== 'string') return;                                 // the lobby speaks strings
  status.textContent = m.data;                                            // 'waiting 3' | 'slot 2'
  joystick.enabled = m.data.startsWith('slot');
});
joystick.onmove = (x, y) => {
  if (joystick.enabled) move.send(encode(x, y));   // a client still in the line sends nothing
};

try {
  await client.join(code);   // named and listening first, so 'waiting 1' 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, a line of text, a stick.
const code = location.hash.slice(1);
const status = document.getElementById('status');
if (!status) throw new Error('missing element');
const joystick = { enabled: false, onmove: (x, y) => console.log(x, y) };   // 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

lobby.on('message', (m) => {
  if (typeof m.data !== 'string') return;                                 // the lobby speaks strings
  status.textContent = m.data;                                            // 'waiting 3' | 'slot 2'
  joystick.enabled = m.data.startsWith('slot');
});
joystick.onmove = (x, y) => {
  if (joystick.enabled) move.send(encode(x, y));   // a client still in the line sends nothing
};

try {
  await client.join(code);   // named and listening first, so 'waiting 1' 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

A waiting client that backgrounds its page keeps its place for a while; a playing client that goes away is swapped out after a shorter grace.

  • The mapping is the host's, in plain data. A line in join order and a slot array. The game reads slot indexes; nothing in it holds a client. The call slots.indexOf(m.client) is the whole translation, and it works because a client is one object for its whole life.
  • Join order needs nothing from the SDK. The clientJoined event fires in join order, so a plain array is the queue. The client.state.joinedAt field is there for a UI that sorts, on the service's clock; presenceSince and connectionSince are the monotonic pair, which is what an away timer subtracts against.
  • change and left are one event object each, handed to the client level and then to the session level, each with its own shape: change is { client, change, previous, at, stopPropagation() } and left is { client, reason, at, stopPropagation() }. The e.at instant is monotonic, so "how long has this one been away" is one subtraction against session.now().
  • The page names lobby and move and attaches its handler before join. Naming rides the hello, and the handler has to be there with it: the host's first lobby.send(client, 'waiting 1') goes out from inside clientJoined, 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' }.
  • Away time per slot is host policy, not an SDK knob. The SDK reports away and present; the host decides what each means for a client in a slot versus one in line, with two timers. A client in a slot that stays away goes to the back of the line rather than out. Two rules keep that honest. The timer reads where the client is when it fires, not where it was when it started. And a slot is only ever handed to a client that is present. Without the second rule, a client that backgrounded its page while waiting could be handed a slot with its five-minute waiting timer still running, and the slot would sit empty when that timer expired.
  • The session's own budgets are the ceiling, not the floor. reconnectBudget and backgroundBudget end a client whatever a host timer intended, so a host timer longer than the budget never fires: this host raises backgroundBudget at open above its own waiting window and keeps every timer inside it. The budget is written as a duration string and the host timers are millisecond readings, so formatDuration is what turns one into the other.
  • A round starts and ends on the host's own two controls. startRound and endRound are bound to them: nothing in the SDK ends a round, and a host that never calls them leaves every client waiting in line.
  • One channel per conversation. lobby carries every status line in order; move carries frames with a different contract. Channels has the delivery rules and the rate buckets.
  • maxHz paces the sender, latestOnly drops at the receiver. The host declares both on its row, the one definition of the channel, and the page only names move. A lossy channel without maxHz is paced at 30 a second, so a 60 Hz stick declares it.
  • A client's left reason names the budget that ran out. LeftReason is a fixed set. reconnectBudget is a client that lost its link for too long; backgroundBudget is a client that backgrounded its page for too long. Two reasons, so a host can tell the two apart.
  • The line is told by unicast, one message per client. A broadcast cannot say a different place to each client, so telling a line of a hundred would be a hundred sends every time it moves, and the service holds one sender to 20 reliable messages a second. The tellPlace helper tells the first ten and nobody else, which is why the line moving costs ten sends however long it gets.
  • maxClients is not the only number a line meets. The project's own concurrentClients is 40 by default and is reached first; raise it to match the line you expect. See Limits and capacity.

Next steps