# A poll with a reveal

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](/docs/guides/script-tag/) 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>
```

```js
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);
};
```

```ts
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);
};
```

## 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](/docs/guides/script-tag/), 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](/docs/guides/client-page/#the-client-key)), or
create one yourself ([Client keys](/docs/guides/credentials/#client-keys)).

```js
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}`;
}
```

```ts
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}`;
}
```

## 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](/docs/guides/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](/docs/guides/limits/).

## Next steps

- [Channels](/docs/guides/channels/)
- [Limits and capacity](/docs/guides/limits/)
- [Client pages](/docs/guides/client-page/)
