Server hosts
Run the host on your own server, and let the host's display only draw what the server sends it.
Quickstart
Five steps. Steps 2 to 4 are one file, server.mjs, and step 5 is the page it serves. The steps
assume a host's display that has already paired: the pairing flow is the sample's.
1. Key, packages and TLS
Make a secret key that holds sessions:open (see Secret keys)
and put it in BITSKIFF_SECRET_KEY. Install
@bitskiff/host-node from the downloads page, and ws. Put a certificate for
your server's name in tls.crt and tls.key. Behind an ingress that terminates TLS, listen on
plain HTTP instead. The host's display needs nothing of ours.
2. Open a session that stays open
import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import { WebSocketServer, type WebSocket } from 'ws';
import { BitskiffHost, qrSvg } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';
const host = new BitskiffHost(); // the secret key comes from BITSKIFF_SECRET_KEY
const pad = host.channel('pad', { delivery: 'lossy' }); // declared before the loop opens
const loop = openForever(host, {
session: { label: 'arena-1', maxClients: 8, hostResumeWindow: '5m' },
}); // eight clients; the host's display is not one of them
process.on('SIGTERM', () => loop.detach()); // a deploy: the session rides the restart
import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import { WebSocketServer } from 'ws';
import { BitskiffHost, qrSvg } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';
const host = new BitskiffHost(); // the secret key comes from BITSKIFF_SECRET_KEY
const pad = host.channel('pad', { delivery: 'lossy' }); // declared before the loop opens
const loop = openForever(host, {
session: { label: 'arena-1', maxClients: 8, hostResumeWindow: '5m' },
}); // eight clients; the host's display is not one of them
process.on('SIGTERM', () => loop.detach()); // a deploy: the session rides the restart
3. Serve the host's display, to paired devices only
// What your pairing flow issued, one line per device: `<device id> <credential>`.
const devices = new Map<string, string>(); // credential -> device id
for (const line of readFileSync('devices.txt', 'utf8').trim().split('\n')) {
const [id, credential] = line.split(' ') as [string, string];
devices.set(credential, id);
}
const deviceOf = (cookie = ''): string | undefined => {
const credential = /(?:^|;\s*)device=([^;]+)/.exec(cookie)?.[1];
return credential === undefined ? undefined : devices.get(credential);
};
const links = new Map<WebSocket, string | undefined>(); // open link -> device id
const toLinks = (text: string | undefined) => { if (text) for (const ws of links.keys()) ws.send(text); };
const codeFrame = () => loop.session && JSON.stringify({ qr: qrSvg(loop.session.joinCode.url) });
const https = createServer({ key: readFileSync('tls.key'), cert: readFileSync('tls.crt') },
(req, res) => res.end(readFileSync('index.html'))).listen(443);
new WebSocketServer({ server: https, verifyClient: ({ req }) => Boolean(deviceOf(req.headers.cookie)) })
.on('connection', (ws, req) => {
links.set(ws, deviceOf(req.headers.cookie));
ws.on('close', () => links.delete(ws));
toLinks(codeFrame());
});
export function revoke(credential: string): void { // your admin page calls this, and removes the line
const device = devices.get(credential);
devices.delete(credential);
for (const [ws, id] of links) if (id === device) ws.close(); // the open link closes too
}
// What your pairing flow issued, one line per device: `<device id> <credential>`.
const devices = new Map(); // credential -> device id
for (const line of readFileSync('devices.txt', 'utf8').trim().split('\n')) {
const [id, credential] = line.split(' ');
devices.set(credential, id);
}
const deviceOf = (cookie = '') => devices.get(/(?:^|;\s*)device=([^;]+)/.exec(cookie)?.[1]);
const links = new Map(); // open link -> device id
const toLinks = (text) => { if (text) for (const ws of links.keys()) ws.send(text); };
const codeFrame = () => loop.session && JSON.stringify({ qr: qrSvg(loop.session.joinCode.url) });
const https = createServer({ key: readFileSync('tls.key'), cert: readFileSync('tls.crt') },
(req, res) => res.end(readFileSync('index.html'))).listen(443);
new WebSocketServer({ server: https, verifyClient: ({ req }) => Boolean(deviceOf(req.headers.cookie)) })
.on('connection', (ws, req) => {
links.set(ws, deviceOf(req.headers.cookie));
ws.on('close', () => links.delete(ws));
toLinks(codeFrame());
});
export function revoke(credential) { // your admin page calls this, and removes the line
const device = devices.get(credential);
devices.delete(credential);
for (const [ws, id] of links) if (id === device) ws.close(); // the open link closes too
}
4. Take input, and send the code and the state
const inputs = new Map<string, string>(); // the game: each client's last input
pad.on('message', (m) => inputs.set(m.client.id, String(m.data))); // hears every session the loop opens
loop.on('open', (session) => {
session.on('joinCode', () => toLinks(codeFrame())); // every 30 s
toLinks(codeFrame());
});
setInterval(() => toLinks(JSON.stringify({ inputs: Object.fromEntries(inputs) })), 50);
const inputs = new Map(); // the game: each client's last input
pad.on('message', (m) => inputs.set(m.client.id, String(m.data))); // hears every session the loop opens
loop.on('open', (session) => {
session.on('joinCode', () => toLinks(codeFrame())); // every 30 s
toLinks(codeFrame());
});
setInterval(() => toLinks(JSON.stringify({ inputs: Object.fromEntries(inputs) })), 50);
5. Draw it
index.html, served from the same origin, so the device cookie the pairing set rides the
WebSocket handshake. It reconnects on its own, with a backoff.
<div id="qr"></div><p id="pause" hidden>Back in a moment</p><pre id="state"></pre>
<script type="module">
const byId = (id) => {
const element = document.getElementById(id);
if (!element) throw new Error(`index.html has no #${id}`);
return element;
};
const [qr, pause, state] = ['qr', 'pause', 'state'].map(byId);
function connect(wait = 1000) {
const ws = new WebSocket(`wss://${location.host}/`);
ws.onopen = () => { wait = 1000; pause.hidden = true; };
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.qr) qr.innerHTML = m.qr; // an SVG the server drew with qrSvg
if (m.inputs) state.textContent = JSON.stringify(m.inputs);
};
ws.onclose = () => {
pause.hidden = false;
qr.innerHTML = ''; // no code on show while the server is away
setTimeout(() => connect(Math.min(wait * 2, 30_000)), wait);
};
}
connect();
</script>
Clients join the way they always do. They scan the code on the host's display into the
sample client page or your own, and send strings on pad.
What a server host is
Some games keep their state in a process on your server. That process is the host. It runs
@bitskiff/host-node, opens a session that stays open, and takes every client's input.
The host's display is a page in the venue. It runs no Bitskiff package. Your server feeds it over your own link, the way a browser host's page feeds its own monitor.
A server host is a Node host that stays up, and it uses the same open-forever loop. There is no separate server package.
The host's display is not a video stream. Bitskiff carries bytes on named channels and no pictures, so the server sends state and the host's display draws it.
The host's display
The host's display is your page on your server's origin. Your server sends it two things:
- The join code. Send
session.joinCode.urlwhen a session opens and on everyjoinCodeevent. The code rotates every 30 seconds. - The state. Whatever your game draws, as often as it needs.
Send the join code only to a host's display you have paired. The SDK does not send it anywhere on its own: the join code is the host's to draw.
Pairing the host's display
Your link to the host's display is authenticated as a device you own. Bitskiff sees none of it and stores none of it. The recommended shape is the device authorization flow of RFC 8628:
- A new host's display shows a one-time pairing code.
- A person approves the code on your own admin page.
- The host's display receives a device credential of yours, kept as an
HttpOnly,Securecookie on your server's origin.
The browser sends that cookie on the WebSocket handshake. A browser WebSocket sets no headers,
and a credential in a query string lands in every access log, so it never goes there. Take the
device's id from the approved record, never from anything the device sends.
The host's display is a device in a public place. Its credential is a live feed of join codes, so whoever steals it can join from anywhere. Revoke the pairing to end the feed, and close its open link at the same time. The last code the thief saw dies within 60 seconds.
A client the thief already admitted keeps its place until the host removes it. If that window is
too long for your project, set its requireToken limit to require a
join token, so a code alone admits nobody.
The host's display holds no key or token of ours. Nothing it holds opens a session, mints a token or reads one.
Get the sample
The server host sample is the whole of this guide as one folder: the server, the host's display
page, the pairing flow with its approval page, a Dockerfile and a Kubernetes manifest. Take it from
the Samples table on the downloads page, unpack it with tar -xzf, and read its
README. Copy it and run it on your own infrastructure. Bitskiff does not host it.
Keys at rest
A Node host keeps one record per slot so it can resume after a restart. A slot is the name the
host is saved under, persist.as, and default when you set none. The SDK seals that record, so no file and
no store holds a key or a token in clear.
- The secret key is read, never written. It comes from
BITSKIFF_SECRET_KEYor from a file your platform mounts, such as a Kubernetes Secret. - One sealed record per slot. It holds the host token, the session id and the host's identity key, together. In the state directory it is one owner-only file.
- The seal uses the secret key. A host that holds a token instead of a secret key seals under
BITSKIFF_STATE_SECRET: at least 32 random bytes you generate. - Old secrets keep working. Put them in
BITSKIFF_SECRET_KEY_FALLBACKS, comma-separated. The SDK tries them in order after the current one, and re-seals a record under the current secret the first time it opens one under a fallback.
A host that holds a token and no state secret cannot seal, so open refuses it before anything is
written:
BitskiffError { code: 'validation', details: { option: 'persist.in', reason: 'no_sealing_secret' } }
Set BITSKIFF_STATE_SECRET, or pass persist: { in: 'none' } to keep nothing.
A record that no secret opens is ignored, and the next open replaces it. The record is deleted on
ended and on close(), and kept on dispose(). Disk encryption underneath is your platform's
layer and does not replace the seal.
Restarts and crashes
Set hostResumeWindow to 5m for a server host. The default is 10s, which covers little more
than a page reload, and the ceiling is 30m.
- A deploy. Call
loop.detach()onSIGTERM. The session runs out its host grace, and the new process finds the sealed record and resumes the same session on the same join code. - A crash. The process never detaches, and the result is the same: the next process resumes inside host grace.
- An overlap. Two processes cannot hold one slot. A replacement waits at most one slot lease, 5 seconds, for its predecessor's lock, and never trusts a process id.
From the moment the server stops until the new one serves and each host's display reconnects, no code is on show and nobody new joins. The clients already in the session keep their places.
Kubernetes
A pod's own disk does not survive a rollout, so the state directory is a volume.
- Run a StatefulSet with one volume claim per replica, mounted at
BITSKIFF_STATE_DIR. - Set
persist.asand the sessionlabelto the pod name. - Set
terminationGracePeriodSecondslonger thandetach()takes. - Each replica is its own host. Give every pod a stable DNS name with a headless Service, expose each one with its own ingress rule, and record in the pairing which replica a host's display belongs to. Never route a host's display through a load-balanced Service.
The sample does all of this.
| Event | What happens | What you do |
|---|---|---|
| A rollout restarts a pod | The new pod finds the sealed record on the same volume and resumes inside host grace | detach() on SIGTERM |
| The secret key rotates | The new pod opens the record under the old key and re-seals it under the new one | Set the new key, move the old one to BITSKIFF_SECRET_KEY_FALLBACKS, and remove it when the overlap ends |
| A pod moves where its volume cannot follow, or the replica count goes down | The pod finds no record and opens a fresh session. The old one ends hostResumeExpired, and its clients are told hostGone | Use a regional disk, or nodeStateStore |
| A rollback to an SDK from before the sealed record | The older SDK finds no slot and opens a fresh session | Nothing: each session costs its host grace |
A platform with no volume
nodeStateStore(backend) keeps the sealed record in your own store instead of the state
directory. It builds the host's persistence and its key store over one backend, so the token and
the key never land in two places. The backend only ever sees sealed records.
A backend has four operations:
| Operation | What it does |
|---|---|
read(name) | The entry's value, or undefined |
write(name, value, ttlMs?) | Replaces the entry, with an expiry when one is given |
createIfAbsent(name, value, ttlMs) | Creates the entry unless a live one exists; true when created |
deleteIfEqual(name, value) | Deletes the entry only while it still holds value; true when deleted |
Expiry is judged on the backend's own clock. Redis (SET NX PX) and a SQL row compared against the
database's now() both qualify. A store with no server-side expiry, such as Cloud Storage or
Firestore alone, does not.
import { BitskiffHost, nodeStateStore, type NodeStateBackend } from '@bitskiff/host-node';
// Your own store: read, write, createIfAbsent and deleteIfEqual over Redis or a SQL row.
const backend: NodeStateBackend = {
async read(name) { /* ... */ return undefined; },
async write(name, value, ttlMs) { /* ... */ },
async createIfAbsent(name, value, ttlMs) { /* ... */ return true; },
async deleteIfEqual(name, value) { /* ... */ return true; },
};
const host = new BitskiffHost({ platform: nodeStateStore(backend) });
import { BitskiffHost, nodeStateStore } from '@bitskiff/host-node';
// Your own store: read, write, createIfAbsent and deleteIfEqual over Redis or a SQL row.
const backend = {
async read(name) { /* ... */ return undefined; },
async write(name, value, ttlMs) { /* ... */ },
async createIfAbsent(name, value, ttlMs) { /* ... */ return true; },
async deleteIfEqual(name, value) { /* ... */ return true; },
};
const host = new BitskiffHost({ platform: nodeStateStore(backend) });
Where the server runs
Run the server in the same region as Bitskiff, us-central1. Input then travels client to
Bitskiff, Bitskiff to your server inside one region, and your server to the host's display. A
server in another region adds two long legs.
The peer transport rarely fits a server host: its clients are not on
the server's network, so a link crosses the internet as the engine does and exposes your server.
Metering
The server is the host: the session's time is its time, and host grace across a deploy is counted as away time. The host's display on your own link is not a participant. It takes no place and no client minutes, and its traffic is yours.
A host's display that joins as a client
You can build the host's display as a page on @bitskiff/client instead. The design does not build
for it, and it costs more. A host's display that joins as a client is metered as a client: it takes
a place, counts against maxClients, concurrentClients and clientMinutesPerDay, and keeps the
idle limit from firing while it is attached. One that is always on uses 1440 client minutes a day.
If you build it anyway:
- Never broadcast the join code. Send it with
channel.send(client, ...)to each client whose subject names a host's display, and to nobody else. Any client on a broadcast could forward live codes. - Join with a token. Your backend mints it with
POST /v1/tokens:sessions:join, bound to the session, with the subject taken from the paired device record and never from the request. The minting key holdstokens:mintwith thesubjectsconditiondisplay:. - Grant it on arrival. A token carries no grants. The host sets
VIEWERwhen the client arrives and ignores its input until then. - Resume, then pair. A reload resumes from its stored record first, and only a place that is gone sends it back to your backend, with a backoff.