2. Protocol and API
The full message reference is CROWDPLAY.md. This is the part a partner integrates against: the two sockets, and how each proves who it is.
Everything is JSON, one object per frame, both directions.
The game socket
Server to server, so a secret is safe on it.
wss://<host>/ws?role=game&tenant=<slug>&channel=<channel>&key=<secret>
key is the secret we exchanged. It is checked in constant time; a wrong one closes with 4001 unauthorised and a wrong or missing channel with 4003 no channel.
One game per channel. A second connection replaces the first, and the replacement gets a fresh session: everybody already in the room is seated again, and any round that was open is refunded rather than left hanging.
Send your catalog as soon as you receive ready. Then you receive one actions batch per tick, and you open and settle whatever rounds you want.
The viewer socket
Browsers cannot hold a shared secret, and you are not going to hand us your user database. So a viewer arrives holding a token your backend minted with your own secret:
wss://<host>/ws?role=crowd&tenant=<slug>&channel=<channel>&token=<viewer token>
We verify the signature, read the id and display name out of it, and that is the entire extent of what we know about that person. No account is created, no lookup happens, nothing is stored against them beyond the session.
A viewer with no token is allowed and becomes a spectator: they see everything and can do nothing. That is deliberate — the honest state, rather than a balance that is a fiction.
The viewer token
You mint these. The format is fixed and small on purpose:
cp1.<base64url(payload)>.<base64url(hmac-sha256)>
The payload is:
{ "sub": "your-user-id", "name": "Display Name", "exp": 1787050000000 }
The MAC is HMAC-SHA256(secret, "cp1." + base64url(payload)).
subis required, and is whatever your side calls that user. Max 100 chars.nameis optional and truncated to 40 characters.expis milliseconds since epoch. Default life is 10 minutes, capped at 24
hours. Long enough to open a page, short enough that a leaked one is worthless.
It is deliberately not a JWT. A JWT invites alg: none, key confusion, and a parser with a decade of CVEs behind it. This is one algorithm, one version, and no negotiation: if the first field is not cp1, it is not a token. The signature is verified before the payload is parsed, so malformed input never reaches a JSON parser on an unauthenticated path.
Minting is about fifteen lines in any language. In Node:
const crypto = require('crypto');
function mintViewerToken(secret, { id, name = '', ttlMs = 600000 }) {
const payload = Buffer
.from(JSON.stringify({ sub: id, name: name.slice(0, 40), exp: Date.now() + ttlMs }))
.toString('base64url');
const mac = crypto.createHmac('sha256', secret)
.update(`cp1.${payload}`).digest().toString('base64url');
return `cp1.${payload}.${mac}`;
}
Interaction without video
wss://<host>/ws?role=view&channel=<name>&media=0
Chat and crowdplay, no video. This is the shape where somebody else owns the player and the audience, and only the interaction layer comes from us.
What the server enforces
So you do not have to, and so a modified client cannot bypass it:
- an action your catalog never declared is refused
- cooldowns and per-tick caps, per viewer
- a stake larger than the balance is refused before anything is booked
- one vote per viewer per poll
- a round that closes while a game is gone is refunded, not stranded
- every settlement is idempotent, so a retried delivery pays once
Currency and settlement
Each tenant has its own wallet and its own ledger, and no tenant's engine can see another's. A viewer's balance under your slug is yours: it is opened, debited and settled entirely within your tenancy.
Movements are written durably as they happen, so a settlement survives a restart, and each carries an idempotency key so a replayed one moves nothing twice.
Part of the IMPAKT crowdplay integration pack. All documents.