Bitskiff

A poll with a reveal

On this page

A presenter's host shows one question at a time, any number of clients answer from their devices, and the host reveals the tally when it chooses. Not a game: clients join late, leave, and come back mid-question, and the host never re-asks. One reliable channel carries the questions and the answers, since an answer must land after its question.

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 under it as host.js beside it, in a directory of its own: the client half below is a second page. The buttons are the presenter's controls.

HTML
<button id="next">next question</button>
<button id="reveal">reveal</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, type Client, type ClientLeft, type Message } from '@bitskiff/host';

// Your deck, what the host's display shows, and the two controls under it.
interface Question {
  id: string;
  text: string;
  choices: string[];
}

const questions: Question[] = [
  { id: 'q1', text: 'Which way?', choices: ['left', 'right'] },
];
const display = {
  ask: (text: string, choices: string[]): void => console.log(text, choices),
  tally: (counts: number[]): void => console.log(counts),   // one count per choice, in choice order
  answered: (n: number, of: number): void => console.log(`${n} of ${of}`),
  done: (): void => console.log('that was the last one'),
};
const nextButton = document.getElementById('next');
const revealButton = document.getElementById('reveal');
if (!nextButton || !revealButton) throw new Error('missing element');

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

// The host declares every channel before it opens.
// host -> client: 'ask <id>\n<text>\n<choice>\n<choice>...' | 'closed <id>'
// client -> host: 'answer <id> <choiceIndex>'
const poll = host.channel('poll');

// 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`.
const session = await host.open({ maxClients: 200 });

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

let current = -1;
let open = false;                                   // whether the question at `current` still takes answers
const answers = new Map<Client, number>();          // this question's answers: Client -> choice index

function askText(question: Question): string {      // one of `questions`, as the wire line
  return ['ask ' + question.id, question.text, ...question.choices].join('\n');
}

session.on('clientJoined', (client: Client) => {
  const question = questions[current];               // late joiner gets the open question
  if (open && question !== undefined && !answers.has(client)) poll.send(client, askText(question));
});

session.on('clientLeft', (e: ClientLeft) => {
  answers.delete(e.client);                          // a left client's answer does not count
  display.answered(answers.size, session.clients.size);
});

poll.on('message', (m: Message) => {
  const question = questions[current];
  if (typeof m.data !== 'string' || !open || question === undefined) return;
  const [verb, id, choice] = m.data.split(' ');
  if (verb !== 'answer' || id !== question.id) return;   // an answer to a question that is over
  const index = Number(choice);
  if (!Number.isInteger(index) || index < 0 || index >= question.choices.length) return;
  answers.set(m.client, index);                      // a changed mind overwrites; the map is per client
  display.answered(answers.size, session.clients.size);
});

nextButton.onclick = () => {
  const closing = questions[current];
  if (closing !== undefined) poll.broadcast('closed ' + closing.id);
  open = false;                                      // closed means closed, on the last question too
  const next = questions[current + 1];
  if (next === undefined) {                          // the last question is answered; nothing to ask
    display.done();
    return;
  }
  current += 1;
  open = true;
  answers.clear();
  display.ask(next.text, next.choices);
  poll.broadcast(askText(next));                     // reaches every connected client that named `poll`
};

revealButton.onclick = () => {
  const question = questions[current];
  if (question === undefined) return;
  const counts = question.choices.map(() => 0);
  for (const index of answers.values()) counts[index] = (counts[index] ?? 0) + 1;
  display.tally(counts);
};
import { BitskiffHost } from '@bitskiff/host';

// Your deck, what the host's display shows, and the two controls under it.
const questions = [                                 // each: { id, text, choices }
  { id: 'q1', text: 'Which way?', choices: ['left', 'right'] },
];
const display = {
  ask: (text, choices) => console.log(text, choices),
  tally: (counts) => console.log(counts),           // one count per choice, in choice order
  answered: (n, of) => console.log(`${n} of ${of}`),
  done: () => console.log('that was the last one'),
};
const nextButton = document.getElementById('next');
const revealButton = document.getElementById('reveal');
if (!nextButton || !revealButton) throw new Error('missing element');

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

// The host declares every channel before it opens.
// host -> client: 'ask <id>\n<text>\n<choice>\n<choice>...' | 'closed <id>'
// client -> host: 'answer <id> <choiceIndex>'
const poll = host.channel('poll');

// 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`.
const session = await host.open({ maxClients: 200 });

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

let current = -1;
let open = false;                                   // whether the question at `current` still takes answers
const answers = new Map();                          // this question's answers: Client -> choice index

function askText(question) {                        // one of `questions`, as the wire line
  return ['ask ' + question.id, question.text, ...question.choices].join('\n');
}

session.on('clientJoined', (client) => {
  const question = questions[current];               // late joiner gets the open question
  if (open && question !== undefined && !answers.has(client)) poll.send(client, askText(question));
});

session.on('clientLeft', (e) => {
  answers.delete(e.client);                          // a left client's answer does not count
  display.answered(answers.size, session.clients.size);
});

poll.on('message', (m) => {
  const question = questions[current];
  if (typeof m.data !== 'string' || !open || question === undefined) return;
  const [verb, id, choice] = m.data.split(' ');
  if (verb !== 'answer' || id !== question.id) return;   // an answer to a question that is over
  const index = Number(choice);
  if (!Number.isInteger(index) || index < 0 || index >= question.choices.length) return;
  answers.set(m.client, index);                      // a changed mind overwrites; the map is per client
  display.answered(answers.size, session.clients.size);
});

nextButton.onclick = () => {
  const closing = questions[current];
  if (closing !== undefined) poll.broadcast('closed ' + closing.id);
  open = false;                                      // closed means closed, on the last question too
  const next = questions[current + 1];
  if (next === undefined) {                          // the last question is answered; nothing to ask
    display.done();
    return;
  }
  current += 1;
  open = true;
  answers.clear();
  display.ask(next.text, next.choices);
  poll.broadcast(askText(next));                     // reaches every connected client that named `poll`
};

revealButton.onclick = () => {
  const question = questions[current];
  if (question === undefined) return;
  const counts = question.choices.map(() => 0);
  for (const index of answers.values()) counts[index] = (counts[index] ?? 0) + 1;
  display.tally(counts);
};

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 code = location.hash.slice(1);
const status = document.getElementById('status');   // your page's own line of text
const view = {
  // `pick` takes the choice index the reader tapped.
  question: (text: string, choices: string[], pick: (i: number) => void): void => {
    console.log(text, choices);
    pick(0);
  },
  closed: (): void => console.log('that question is closed'),
};

const client = new BitskiffClient({ key: 'bsk_pk_...' });   // your client key
const poll = client.channel('poll');               // named before join, so the open question lands

poll.on('message', (m: Message) => {
  if (typeof m.data !== 'string') return;
  const [head = '', ...rest] = m.data.split('\n');
  const [verb, id = ''] = head.split(' ');
  if (verb === 'ask') view.question(rest[0] ?? '', rest.slice(1), (i) => poll.send(`answer ${id} ${i}`));
  if (verb === 'closed') view.closed();
});

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

const code = location.hash.slice(1);
const status = document.getElementById('status');   // your page's own line of text
const view = {
  question: (text, choices, pick) => {              // `pick` takes the choice index the reader tapped
    console.log(text, choices);
    pick(0);
  },
  closed: () => console.log('that question is closed'),
};

const client = new BitskiffClient({ key: 'bsk_pk_...' });   // your client key
const poll = client.channel('poll');               // named before join, so the open question lands

poll.on('message', (m) => {
  if (typeof m.data !== 'string') return;
  const [head = '', ...rest] = m.data.split('\n');
  const [verb, id = ''] = head.split(' ');
  if (verb === 'ask') view.question(rest[0] ?? '', rest.slice(1), (i) => poll.send(`answer ${id} ${i}`));
  if (verb === 'closed') view.closed();
});

try {
  await client.join(code);
} catch (error) {
  if (!isBitskiffError(error)) throw error;
  // A refused join leaves the handle `ended`. Try again on a new `BitskiffClient`.
  if (status) status.textContent = `could not join: ${error.code}`;
}

Design notes

  • Late join is the host's job, in one line. The SDK replays nothing. The clientJoined event runs with the client already able to receive, so the host sends the open question there. The channel was named and listening before join, so that first unicast is not { dropped: 'notListening' }.
  • A reconnect changes nothing here. A client that drops mid-question and returns is the same object, so its entry in answers survives and clientJoined does not fire again; only clientLeft removes a vote. Nothing in the host tracks ids.
  • One channel, because order matters. ask, then answer, then closed must stay in order per client. A second channel would buy nothing and could reorder a closed against an answer.
  • The host acknowledges nothing per answer, on purpose. 200 clients answering at once would be 200 reliable replies from one sender, all in the same second. The client's own view confirms the tap, and the count on the host is the feedback that scales: one message when the question closes, not one per person. The receiving client holds each sender to 20 reliable messages a second and drops the excess (Channels), so keep any per-client chatter well under that.
  • A tally is a fold over a Map keyed by client. No ids, no roster diffing.
  • session.clients.size is the denominator. The live roster is the count of who could still answer.
  • The deck has an end. nextButton reads the next question before it moves, so the click after the last one closes the question and says so rather than throwing on an index nobody wrote.
  • open is what closes the question, not current. The broadcast tells the clients a question is over; open is the host saying the same thing to itself. Without it the last question stays at current after the deck runs out and a late answer would still be counted.
  • The message shape is yours. 'answer <id> <i>' and a newline-joined ask are the whole protocol here; a richer payload calls for an encoding of your own, which is the one thing every side of this example agrees on: nothing between the two ends reads what you send.
  • maxClients is not the only number 200 clients meet. The project's own concurrentClients is 40 by default and is reached first; raise it to match before a session this size fills. See Limits and capacity.

Next steps