# 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](/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.

```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>
```

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

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

## 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](/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 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}`;
}
```

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

## 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](/docs/guides/channels/) has both.
- Nothing in the game knows what a session or a channel is on the wire.

## Next steps

- [Channels](/docs/guides/channels/)
- [Controller input](/docs/guides/controller/)
- [Lifecycle](/docs/guides/lifecycle/)
