Your own phone page

A new project's join code opens our sample phone page, so a first host needs no phone code at all. The day you ship, the phone page is yours.

The sample page

It is one page for everybody—a button, a joystick and a text field, with a readout your host writes—and it draws those same three controls whatever host it joins. It is live at /join/, which is where a scanned code lands, and its address is the join URL a new project starts with; the console's project settings is where you see it and where you replace it.

One control per channel, and each message is the value itself. Nothing carries a tag, because the channel is what says which kind of message it is:

ChannelDeliveryWho sendsEach message is
pressreliablethe page"down" when the button goes down, "up" when it comes back
textreliablethe pagethe word that was typed, "hello"
movelossythe page{ "x": 0.5, "y": -0.2 }, sixty times a second
readoutreliablethe hosta string the page shows in its readout

The joystick is the one row that is JSON, because two numbers need two names. A press and a word arrive as the value and nothing around it, so a host reads them with no JSON.parse at all:

import { BitskiffHost, everyone } from '@bitskiff/host';

const session = await new BitskiffHost({ key: 'mrc_pk_...' }).open();

session.channel('press').on('message', (m) => console.log('press', m.from.id, m.data));
session.channel('text').on('message', (m) => console.log('typed', m.from.id, m.data));
session.channel('move', { delivery: 'lossy' }).on('message', (m) => {
  if (typeof m.data !== 'string') return;                    // this page never sends bytes
  const { x, y } = JSON.parse(m.data) as { x: number; y: number };
  console.log('move', x, y);
});

session.channel('readout').send(everyone, 'you are on');

Those four names are this page's own. Nothing on the platform checks them, no session is refused for sending something else, and your own phone page never has to know they exist. The page is for getting started: a project still on it at 100 sessions is warned in the console and on the command line, and at 1000 its phones are refused with a row saying the project has a join page to set.

A page of your own

npm install @bitskiff/client
import { BitskiffClient } from '@bitskiff/client';

const client = new BitskiffClient();
await client.start('1CSESSION1ABC123');          // resolves when you are seated

const press = client.channel('press');
client.channel('readout').on('message', (m) => console.log(m.data));
press.send('down');

It is the host side's mirror image—the same names, the same deliveries, the same string or bytes—with no to on a send, because a phone has one host.

You pass the code. The SDK never reads location and never parses a link. The host's session.joinCode.code is exactly the string start() takes: your page finds it in whatever its own link carries and hands it over unchanged. Do not split it or trim it—it carries more than the digits a person reads, and a page that passes half of it joins with nothing to check the host against.

Point the sessions at your page

Set joinUrl to it in the console's project settings. That is the whole of the setup: the page needs no key of its own and no allowlist entry.

Setting it also turns the guard on. A session's join URL says which client the session is for, so from that moment a phone at your page joins your sessions and a copy of @bitskiff/client running somewhere else does not: it is refused before it takes a seat, with wrongClient. Scheme, host and port are compared—https://play.example.com and https://play.example.com:8443 are two origins—and paths, queries and fragments stay yours.

Serve it with a CSP header

Serve the page with a Content-Security-Policy response header carrying at least frame-ancestors 'none'. The <meta http-equiv> form does not count—browsers ignore frame-ancestors there—and a framed page holding a live seat is somebody else's session taking the taps.

A page has to be served either way: opened from disk it has no origin, and the join is refused.

What the page shows

Five arms cover every state a phone is in once it has started:

import { BitskiffClient } from '@bitskiff/client';

const client = new BitskiffClient();
const show = (banner: string) => console.log(banner);

client.on('state', (s) => {
  switch (s.phase) {
    case 'joining':
      return show('connecting');
    case 'connected':
      return show(s.host === 'away' ? 'the host stepped out' : 'ready');
    case 'reconnecting':
      return show('reconnecting');
    case 'suspended':
      return show('try again');                // `client.retry()` is what the button calls
    case 'ended':
      return show(s.ended?.reason ?? 'ended');
  }
});

await client.start('1CSESSION1ABC123');

suspended is the SDK having stopped trying on its own with the seat not known to be gone; client.retry() starts it again, and start() stays a once-per-handle call. Only ended is final.

Native apps

A native app, a signage player or a game engine sends no browser origin, so it says where it is once:

import { BitskiffClient } from '@bitskiff/client';

const client = new BitskiffClient({ origin: 'https://play.example.com' });
await client.start('1CSESSION1ABC123');

That is the client's own word for it: it keeps a code from being scanned into the wrong app.

A load generator, a test harness or an embedded controller uses createNodeClient from @bitskiff/client/node—the same factory with the browser's pieces swapped for the runtime's, so nothing else about the session changes and there is no second package to install.