SDK helpers
Three small modules, on any stack, for a match from lobby to results, what only the host decides, and bots in empty seats. AI tools read the same at onceworlds.com/sdk-helpers.md.
Three small modules for the parts of a multiplayer game that are easy to get wrong: running a match from the host's page, deciding
things there that nobody else can forge, and filling empty seats with bots. They work with any way of drawing a game (canvas,
Phaser, three.js, the DOM) and need nothing but the room from onceworlds.rooms.join(). They are optional: the SDK every game gets
doesn't include them.
import { createMatch, defaultView } from '/__onceworlds/v1/match.js';
import { createHost, t } from '/__onceworlds/v1/host.js';
import { createBots } from '/__onceworlds/v1/bots.js';
Games import them from their own address, as above, on Onceworlds and under onceworlds serve. With a bundler, install
@onceworlds/sdk and import @onceworlds/sdk/match, /host and /bots. v1 keeps its API: new versions add to it.
Every page runs the same code. The host's page also runs the rules. Nothing in the helpers throws into your game: a bad message is refused, and an error in one of your functions is printed in the console and the game carries on.
host: what only the host decides
const room = await onceworlds.rooms.join({ maxPlayers: 2, lobby: 'card' });
const host = createHost(room);
const board = host.state('board', {
schema: { cells: t.array(t.oneOf('', 'x', 'o'), 9) },
initial: { cells: Array(9).fill('') },
});
const move = host.rpc('move', {
args: { cell: t.int(0, 8) },
rate: 4, // calls a second per player
validate: (from, { cell }) => board.get().cells[cell] === '' || 'taken',
run: (from, { cell }) => board.update((b) => { b.cells[cell] = from.id === room.host ? 'x' : 'o'; }),
});
canvas.addEventListener('pointerdown', (e) => move({ cell: cellAt(e) })); // any page: the host runs it
board.on(draw);
// First come, first served, by the room's clock: two players reaching one chair.
const chairs = host.state('chairs', { schema: t.record(t.id(), 4), initial: {} });
const sit = host.claim('sit', {
args: { chair: t.int(0, 3) },
key: (a) => String(a.chair),
validate: (c) => !chairs.get()[c.args.chair] || 'taken',
apply: (winner) => chairs.update((c) => { c[winner.args.chair] = winner.from.id; }),
});
const result = await sit({ chair: 2 }); // 'won' | 'lost' | 'rejected' | 'timeout'
// The same shuffle on every page, and for the next host.
const deck = host.random(`deck:${room.match.id}`).shuffle(CARDS);
createHost(room, { onReject? }).host.isHost,host.dispose().host.state(name, { schema?, initial? })→{ get(), set(value), update(fn), on(fn) }: room state underhost:<name>, up to 15 KB.host.rpc(name, { args?, validate?, run, rate = 10, maxBytes = 2048, spectators = false })→call(args).host.claim(name, { args?, key?, window = 150, validate?, apply, rate, spectators })→call(args), a promise of its result.host.callAs(bot, name, args): a bot uses an rpc or claim (see bots).host.random(name): seeded by the match.- One
createHostper room. Its messages carry a$owfield: a game's ownroom.on('message')handler can skip those. - Schemas:
t.int(min, max),t.number,t.string(max),t.boolean(),t.oneOf(...),t.id(),t.array(item, max),t.object({...})(a plain{...}is one),t.record(value, maxKeys),t.optional,t.nullable,t.any(maxBytes), or a function returning true, false or a reason.check(schema, value)says what is wrong, or null.
What it prevents:
- Another player writing the score. Host state lives under a
host:key: the room refuses anyone else's write. - Believing a message. Every call and claim is checked before your code sees it: its size, its shape (unknown fields are refused),
who sent it (in the room; during a match, one of its players unless
spectators: true) and how often.validategets checked arguments only. - A buggy or hostile host breaking everyone's screen. Reads are checked against the schema too: a value that doesn't fit is ignored and the last good one stays.
- "Who was first" by whichever message the host heard first. Claims are settled by when the room took them in.
Math.random()in shared things. A new host deals a different deck.host.random(name)is the same everywhere.
match: lobby to results
const room = await onceworlds.rooms.join({
maxPlayers: 6,
minPlayers: 1, // one player can start a match with bots
lobby: 'card', // the platform draws the lobby and its one countdown
settings: [{ id: 'rounds', label: 'Rounds', options: [3, 5], default: 3 }],
});
const match = createMatch(room, {
rounds: 'rounds', // a lobby setting's id, or a number
roundSeconds: 30,
startRound: (m) => ({ target: m.random(`target:${m.roundId}`).int(100), hits: {} }), // the round's shared data
onTick: (m, dt) => { /* host: move things, m.setData(...), m.endRound() when it's decided */ },
score: (m) => m.data.hits, // { [seatId]: points }, or [[first], [second, tied]], or ['winner']
});
match.on('change', draw); // or read match.* in your frame loop
match.on('results', (standings, mine) => {
if (mine?.place === 1) onceworlds.badges.award('winner');
});
defaultView(match); // a plain text panel to start with: replace it with your own screens
Read: match.phase ('lobby' | 'countdown' | 'intro' | 'round' | 'roundEnd' | 'results'), paused, round, rounds, roundId,
seats (players then bots, each { id, name, bot, me, away, left, skill }), me, spectating, scores, standings, last (the
round just played), results, turn, data, secondsLeft, countdown, settings, and lobby (players, canStart,
waitingFor) for a game that draws its own lobby. Do: ready(on), start(), endTurn(); the host: setData(value or fn),
endRound(outcome?), skip(), endMatch().
Rules: rounds, maxRounds, roundSeconds, introSeconds (0), roundEndSeconds (3), resultsSeconds (10; 0 waits for the
host's endMatch()), points ([3, 2, 1] by place), admit ('next' or 'round'), data (a schema), turns, and the host's
startRound, onTick, score, over (end early), final (final placings), resume, onTurn, turnTimeout.
Turn-based: turns: { seconds: 30, order: 'seat' | 'random', perRound, awaySeconds: 10 }. The player on turn moves with your rpc,
whose run calls match.endTurn(); turnTimeout(m, seat) makes a default move for a player who ran out of time.
What it prevents:
- Two countdowns. The room counts down once and the helper adds none. The platform draws it, unless the game joined with
ownCountdown: true: then drawmatch.countdownyourself. - A match starting when someone joins. It never starts by itself: the host starts once everyone else is ready (a public server starts when everyone is).
- A match that dies with its host. Everything is in one record the host writes (
host:ow.match), with deadlines on the match clock. When the host reloads, drops or leaves, the next host carries on from it andresume(m)is called on its page. Keep what the host decides inmatch.dataorhost.state, never only in a variable. - Timers running through a pause. Deadlines are in match time, which stands still while the match waits.
- Latecomers thrown into a round. They watch (
match.spectating); withadmit: 'round'they join at the next round, in a bot's seat if there is one. - Forged results, and quitting to dodge a loss. Only the host writes results; each player's page reports a ranked result itself
and the platform rates the match when the reports agree. Players who leave keep their seat in the standings.
resultsfires once per match on each page: a reload during the results doesn't fire it again.
bots: filling empty seats
createBots(match, {
host, // bots act through the game's own rpcs and claims
fill: 4, // seats up to 4, players first
every: 600, // ms between thoughts, varied per bot (less skilled bots are slower)
think(view) {
if (!view.myTurn) return null;
const free = board.get().cells.flatMap((c, i) => (c ? [] : [i]));
return { call: 'move', args: { cell: view.random.pick(free) } };
},
});
view has bot (its seat), skill (0 to 1), match, data, state (the room's) and myTurn, and random, a stream of its own.
think returns { call, args }, a list of them, or nothing. Without a match, createBots(room, {...}) keeps a world at fill
seats as players come and go.
What it prevents:
- Bots that cheat. Their actions go through the same rpc checks and rate limits as a player's.
- Bots on every page, or none after the host leaves. Only the host's page thinks for them, and a new host takes them over.
- Bots faster than people. Each thinks at its own pace, and a moment after its turn begins.
- Bots keeping players out. Players always come first, and a player let in later takes a bot's seat.
An example
packages/sdk/test/fixtures/gem-grab/game.js is a whole game on the three: 2-6 players race for gems on a canvas, a chest is a
claim, bots fill the seats. Its tests play whole matches between pages while the host reloads, players drop, the host changes during
a pause, a latecomer arrives and a page sends garbage.