Onceworlds
SDK helpers

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);

What it prevents:

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:

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:

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.