Onceworlds
SDK

SDK

What every game gets as window.onceworlds, and the platform's rules. AI tools read the same at onceworlds.com/agents.md.

Workflow

Make it fun: game design

Players find games in a grid of thumbnails, often on a phone, and leave within seconds if they don't get it. Make games for everyone: a teenager and an adult should both want to play, and a younger player should still understand it. These are defaults from games that worked and games that didn't; the creator's idea always wins.

The idea

The first minute

While playing

Juice: every action answers

Screens anyone can read at a glance

Rules of the platform

SDK reference (window.onceworlds)

const ow = window.onceworlds;

// Player: a guest until they sign in to an Onceworlds account (their saves carry over)
const player = await ow.player.get();       // { id, name, guest }
// Guests: the platform's name box (the platform menu has it too). Resolves with
// the player after a change, or null (cancelled, signed in, or standalone).
// Signed-in players change their name in Settings, so offer it only to guests.
if (player.guest) await ow.player.rename();

// Avatars: every player has one (dressed up on Onceworlds). Draw it instead of a
// plain shape: an SVG URL for anyone's id ('head' for a headshot), or null
// outside the platform. It works on canvases too.
const url = await ow.player.avatarUrl(someId, 'head');
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = url;                              // then ctx.drawImage(img, x, y, 48, 48)

// The full avatar ('full', 120×210 SVG units) stands facing you with its arms
// and legs apart, so a game can cut it into parts and animate them (walk, wave,
// sit). Each part is [x0, y0, x1, y1, pivotX, pivotY]; the pivot is the joint.
// Draw the image at 1.5× or 2× on a canvas, then drawImage each box, rotated
// around its pivot: legs, then arms, torso and head. Left/right as you look.
const RIG = {
  head: [0, 0, 120, 88, 60, 86],        // (hats and hair included)
  torso: [38, 88, 82, 142, 60, 140],
  armLeft: [22, 90, 38, 144, 31, 96],   armRight: [82, 90, 98, 144, 89, 96],
  legLeft: [40, 136, 60, 198, 51, 142], legRight: [60, 136, 80, 198, 69, 142],
};                                       // the ground shadow is below y 198

// The avatar as data, to dress your own characters as their players (a 3D figure,
// a team-colored sprite): each part is an index into the platform's lists, null
// outside the platform. Skin tones, colors, faces, hats and hairstyles, patterns.
const look = await ow.player.avatar(someId); // { skin, shirt, pants, face, hat, top }

// Public variables from the dashboard (Environment tab): feature flags, tuning.
// Available immediately, change without a redeploy, and players can see them
// (so keys go in secrets; the dashboard refuses variables that look like keys).
// Up to 50 variables, 4 KB each and 16 KB together: big data belongs in a file.
const doubleXp = ow.env.DOUBLE_XP === 'true';

// Saves: per player, per game, JSON values up to 64 KB, 100 keys, 1 MB in total
// (128 KB until a guest signs in). The platform paces them for you: a burst of 8,
// then about one write a second per player; writing a key again before its last
// write went out keeps only the newest value, and reads return what you just wrote.
// Save on meaningful events (level done, every few seconds at most), never every frame.
// (ow.save is also ow.data.player: see "Data that outlives a session" for a server's world and a game's shared values.)
await ow.save.set('progress', { level: 3 });
const progress = await ow.save.get('progress');   // null if never set
await ow.save.delete('progress');
const keys = await ow.save.list();

// Badges: listed in onceworlds.json (`badges`) or made in the dashboard, each with an id.
// A game has at most 50, so pick milestones that matter. award() resolves true the first time (the platform shows a notification) and
// false if the player already has it, the id doesn't exist, or the player is a
// guest (guests can't earn badges or post scores; the platform asks them to sign
// in). Awards come from the player's browser, so don't gate anything valuable on them.
await ow.badges.award('first-win');
const badges = await ow.badges.list();  // [{ key, name, description, icon, earned, earnedAt }]
const has = await ow.badges.has('first-win');

// Leaderboards: each signed-in player's best score per board (top 100). A board
// starts with its first score, so use a fixed set of names (25 boards at most per
// game). Scores come from players' browsers: anyone can post any number, so keep
// boards for fun, not prizes. Ranks count up to 10,000 (then 10001).
await ow.leaderboards.submit('wins', 12);                         // { best, rank } or null for guests
await ow.leaderboards.submit('fastest-lap', 41.2, { lowerIsBetter: true });
const { entries, me } = await ow.leaderboards.top('wins', { limit: 10 });
// entries: [{ rank, player: { id, name }, score }]

// The platform's clock (ms): same for every player, not the device clock.
// Use it for anything that runs while nobody plays: growth, cooldowns, restocks.
const now = ow.now();

// Phones and tablets: on-screen controls, drawn by the platform while the player
// plays by touch. The stick and buttons press keys in your game, so keyboard
// controls work unchanged.
ow.controls.set({
  stick: 'wasd',                                   // or 'arrows', or 'analog' (presses no keys)
  zone: 'wide',                                    // or 'corner': where the stick catches the thumb (taps there belong to it)
  buttons: [                                       // up to 4; the first is the biggest
    { id: 'jump', label: 'Jump', key: ' ' },
    { id: 'use', label: 'Use', key: 'e' },
  ],
});
ow.controls.set(null);          // none, e.g. on a title screen; set() again when play starts
ow.controls.stick;              // { x, y } from -1 to 1 (y points down), 0 when let go
ow.controls.pressed('jump');    // true while held
ow.controls.presses('jump');    // how many times it went down: if it grew since your last frame, that's a press (even a tap already let go)
// A button presses the `key` you gave it: a real keydown and keyup with key, code and keyCode set, so a keyboard
// handler written for `e.key`, `e.code` or `e.keyCode` works unchanged (and pressed('jump') is the same state).
// stick: 'wasd' or 'arrows' presses those keys; 'analog' presses none and only fills ow.controls.stick. The stick and
// the keyboard are independent, so a game can read both and sum them.
ow.controls.touch;              // true while the player plays by touch (controls on screen)
ow.ui.setOrientation('landscape');   // or 'portrait'; the default 'any' plays either way

// Settings the player chose in the platform menu (Graphics: Auto, Low, Medium, High; Reduce motion; Performance
// stats). Read them instead of adding your own quality menu: the player finds them in the same place in every game.
ow.settings.quality;         // 'low' | 'medium' | 'high': draw at this. With Auto, the platform times your frames
                             // and steps down when they're slow and back up carefully (asking for it starts the meter)
ow.settings.scale;           // 0.6 | 0.8 | 1: the share of the screen's resolution to render at this quality
ow.settings.pixelRatio(2);   // min(devicePixelRatio, 2) × scale: size a canvas with it (canvas.width = cssWidth * pr)
ow.settings.reducedMotion;   // the player (or their system) wants less motion: skip shake, flashes, big sweeps
ow.settings.choice;          // 'auto' | 'low' | 'medium' | 'high': what the player picked. A game with its own adaptive quality
                             // (a 3D game measuring its own frame time) reads this instead of `quality`: a fixed choice is its
                             // ceiling, and with 'auto' its own governor decides (don't read `quality` then: it starts the SDK's)
ow.settings.on('change', (s) => {});  // quality or reduced motion changed (resize the canvas, drop the particles)

// Platform events
ow.on('pause', () => {});    // player opened the platform menu
ow.on('resume', () => {});
ow.on('audio', ({ volume, muted }) => {});   // already applied; for your own UI only
ow.on('player', (player) => {});             // your name changed (a guest picked or changed it)

Multiplayer rooms

A multiplayer game is a public game or a friends game. Either way the game calls rooms.join() once, as it starts, and the platform decides where the player goes:

const room = await ow.rooms.join();                           // a public server (16 players)
const room = await ow.rooms.join({ mode: 'duel', maxPlayers: 2 });      // matched 1v1 (queues are per mode)
const room = await ow.rooms.join({ mode: 'tdm', maxPlayers: 8, teams: 2 }); // 4v4, balanced teams
const room = await ow.rooms.join({ private: true, maxPlayers: 4 });     // a friends game: your own server
const room = await ow.rooms.join({ name: 'arena-7f3k' });     // a named room the game manages itself
                                                              // (rarely needed: see "Lobbies and parties")
const room = await ow.rooms.join({ maxPlayers: 8, minPlayers: 3, lobby: 'card' }); // a party game with the
                                                              // platform's lobby (see Matches below)

room.me              // { id, name, presence, team }: you (name: the player's account name, or
                     // the name a guest picked; the platform asks guests before their first room)
room.players         // Map<id, { id, name, presence, team, guest? }>, including you
room.online          // the players connected right now: room.players also holds the ones whose seat is held (see below)
room.host            // id of the host. It stays with them while they're connected; when they leave or drop it goes to
                     // a private server's owner if they're in it, else the longest-connected player (a match's players,
                     // and players looking at the game, first), and stays there. A host whose page has been in the
                     // background for 10 s (no frames, throttled timers) hands it to a player who is looking.
room.isHost          // true if that's you and you're connected
room.state           // shared key/value object: read it any time
room.kind            // 'public' | 'private' | 'named' ('solo' outside onceworlds)
room.invite          // private servers: the invite link
room.teams           // number of teams (0 = none); player.team is 1..teams
room.on('rename', (player) => {});  // someone (maybe you) changed their name: redraw name tags
ow.ui.showInvite();  // the platform's invite picker (friends, or a link to copy)

// Lobbies: ready up. A flag on each player's connection, so it's gone when they leave or
// reload and survives a brief reconnect. The host starts; everyone else readies.
room.setReady(true);         // you're ready (false: not). Not fired back at you.
room.allReady                // everyone but the host is ready (true when you're alone)
room.notReady                // the players still to ready up (never the host): "Waiting for Ann, Bo"
player.ready                 // true while that player is ready
room.on('ready', (player) => {});  // someone's flag changed, or the host cleared them all
room.clearReady();           // host only: everyone un-readies (a game starts, or ends back in the lobby)
// A public server has no real boss, so there everyone (the host too) readies and the room
// starts the match by itself (see Matches below).

// Matches: the room keeps the lobby → countdown → match → lobby cycle, the same for every game and on
// every page, so you don't. Optional: a world with no rounds never needs it.
const room = await ow.rooms.join({ maxPlayers: 8, minPlayers: 3 });  // minPlayers: the fewest a match needs
room.match           // { phase: 'lobby' | 'starting' | 'playing', n, min, id, seed, participants,
                     //   startsAt, startedAt, paused, waitUntil }: the same for everyone
room.participants    // the players in the match, connected or away ([] in a lobby)
room.spectators      // connected players who aren't in it: they came late, or sat it out, and watch
room.spectating      // that's you; room.isParticipant(id) asks about anyone
room.running         // a match is playing and not waiting: run your simulation and clocks on this
room.matchNow()      // ms of match time, the same for everyone; stands still while the match waits. 0 in the lobby and
                     // during the countdown ('starting'), and counts up from 0 once the phase is 'playing'. To tell
                     // lobby, starting and playing apart (what to draw), read room.match.phase
room.canStart        // (host) enough players are connected and everyone else is ready
room.settings        // the lobby settings you declared (rooms.join({ settings })): { rounds: 3, time: 80 }, the host's
                     // choices, frozen as the match started with them (see Matches); room.setSetting(id, value) for a
                     // lobby of your own; room.on('settings', (values) => {})
room.startMatch();   // host of a private server or a named room. Public servers start themselves.
room.endMatch();     // host: the match is over. Back to the lobby, flags cleared, matchmaking reopens
room.pauseMatch();   // host of a private server or named room (pauseMatch(false) goes on)
room.admit(ids);     // host: watchers join the match now (a checkpoint your game allows latecomers at)
room.on('starting', (match) => {});   // countdown began; match.startsAt is when play begins
room.on('matchstart', (match) => {}); // play begins: match.participants play, everyone else watches
room.on('matchend', (match, previous) => {}); // over (previous.phase 'playing') or the countdown was stopped
room.on('matchpause', (match) => {}); // too few of its players are connected, or the host paused it
room.on('matchresume', (match) => {});
room.on('match', (match, previous) => {});  // every change, if you'd rather diff
room.on('error', ({ code, message }) => {}); // the room refused something, or a call never reached it; also printed in the console as
                     // "[onceworlds] message (code)". A start says why: not_host, few_players, not_ready, bad_phase (a match is already
                     // under way), slow_down; closed and disconnected when startMatch, endMatch, pauseMatch or admit never reached the
                     // room. The host of a private server also hears why a countdown stopped (few_players, not_ready) and why a match
                     // ended by itself: match_idle (nobody touched the game for 5 min) and match_abandoned (too few players for 2 min)

// The connection can drop (a phone waking up, Wi-Fi changing). The platform reconnects by
// itself and shows "Reconnecting…"; your game hears about it:
room.connected               // false while it's down
room.on('disconnect', () => {});  // pause what only the host should do: room.isHost is false until you're back
room.on('reconnect', () => {});   // back: players, state and host are current (join, leave, state and host
                                  // events fired for anything that changed while you were away)

// Seat hold. A player whose connection drops, or whose page reloads, keeps their seat: 15 s after
// a reload, 30 s after a lost connection, 90 s when their page had gone to the background first (a phone
// that locked or switched apps). They stay in room.players with player.connected === false
// (the player list dims them) and keep their team, ready flag and presence; nobody hears a leave. A page that
// reloads gets its last presence back in room.me.presence (null for a player who never sent one): carry on from it.
room.on('idle', (player) => {});          // player.idle: connected, but 30 s without touching the game (pointer, keys,
                                          // touch, gamepad): probably not at the screen. Skip their turn; don't wait on
                                          // their answer. A running match everyone has walked away from ends after 5 min (counted
                                          // from the match's start at the earliest). A page driven by a script touches nothing:
                                          // to the room it has walked away, so a scripted match longer than that needs input events.
room.on('away', (player) => {});          // their connection went; their seat is held
room.on('back', (player, fresh) => {});   // they're back as the same player id. fresh: a new page load (a
                                          // reload), which has none of what the old page kept in memory
// A player who doesn't return in time is a `leave`, and coming later is a `join`. So a reload is
// away then back, never leave then join, unless it takes longer than the hold: keep score and place
// under the player id. Held seats still count toward a server's size. For "is everyone here?" and
// "everyone has answered" use room.online (the host's ready check, room.allReady, already skips
// players who are away), and decide how long your game waits for an away player (a turn, a round).

// Host only:
room.setTeam(playerId, 2);   // move a player (room.on('team', (player) => {}))
room.setOpen(false);         // stop newcomers (invite links are refused). A public server closes itself while its
                             // match plays; there the host's setOpen(false) starts the match with everyone connected
                             // and setOpen(true) ends it. New games let players ready instead (room.setReady).
room.setOpen(true);          // back to accepting players (room.on('open', (open) => {}))
room.kick(playerId);         // private servers and named rooms: remove a player; they can't come back
room.transferHost(playerId); // hand the host role to a connected player (a private server's owner can take it
                             // back the same way). The platform's player list has the button in friends' servers.

// Anyone: vote to remove a player. It takes a majority of the others (at least
// 2 votes); only players who have been in the server a minute vote, and players
// on one network (a home or school) count once. Public servers have no real host,
// so this is how they remove people.
room.voteKick(playerId);
room.on('votekick', (player, votes, needed) => {});

// Parties: friends in a private server (or a named room) queue together. The host
// calls this; everyone in the room lands in one server (one team, if it fits).
// Public servers refuse it: their host is a stranger. A ranked party may take at
// most half of the server it asks for (two friends need maxPlayers: 4 or more), so
// a party can't fill a ranked server and rate itself.
const match = await ow.rooms.join({ mode: 'tdm', maxPlayers: 8, teams: 2, party: true });
ow.rooms.on('moved', (match) => {});  // everyone else: the platform moved you
// The old room closes with reason 'moved'.

// Ranked: matched with players of similar rating (the range widens the longer a
// server waits for players). A ranked server is a public server, so its match is
// the room's (see Matches): everyone readies (room.setReady, or the platform's
// lobby), the room counts down and starts it, and its players stay in it if they
// leave. When it ends, EVERY player's game reports the same result; a player's
// first report is final, and guests aren't rated. Results count from 30 seconds
// into the match, the platform settles them from the reports (a loser who quits
// or stays silent still loses), and the next match is rated a minute after the
// last at the earliest. Two accounts are rated against each other at most
// 5 times a day. Results are what players' pages say: right for a ladder, not for prizes.
const room = await ow.rooms.join({ mode: 'duel', maxPlayers: 2, ranked: true });
room.reportResult({ winners: [winnerId] });   // or { winningTeam: 1 }, { draw: true },
                                               // { ranking: [[1st ids], [2nd ids], ...] }
room.on('rated', (changes, disputed, capped) => {});  // [{ id, rating, delta }]; player.rating too.
                                                      // disputed or capped: nothing changed
await ow.ratings.get('duel');                 // { rating, games } or null (guests aren't rated)
await ow.ratings.top('duel', { limit: 10 });  // [{ rank, player, rating, games }]

// 1. Presence: your per-player data (position, color, score). Call as often as
//    you like; the SDK sends at most 20 updates/s (15 in a room of 30, see the budget below). Keep it under 1 KB.
room.setPresence({ x, y, hue, score });
room.on('presence', (player) => { player.presence; });   // the latest update: it jumps at each one (up to 20 a second)
// Draw other players with presenceAt: a moment in the past (100 ms), between the two updates around it, so they
// glide. Numbers slide (also inside arrays and objects); name angle fields so they turn the short way round; `snap`
// draws a teleport as a teleport. Nothing is guessed past the newest update.
const at = room.presenceAt(player.id, { angles: ['heading'], snap: 8 });   // { x, y, heading, ... } to draw
// snap: a number field that jumps by more than this between two updates is a teleport and isn't slid across (same
// units as the field: 8 means 8 world units or pixels). Numbers in presence slide, `angles` fields turn the short way.

// 2. Shared state: world data everyone sees. Last write wins. The server keeps
//    it until everyone leaves. Values under 16 KB, at most 256 keys.
room.setState('doors', { red: 'open' });
room.setState('doors', null);                 // delete
room.on('state', (key, value, fromId) => {});
// Your own setState changes room.state at once and doesn't fire 'state' (read room.state after writing). The event
// is for other players' writes, and for the room correcting one of yours.
// Anything the host's rules decide (the round, the scores, the results, the world the host simulates) goes under a
// `host:` key: only the host can write or delete it, so no other page can forge a win or end a round. Anyone else's
// write does nothing (room.on('error') says not_host). Everyone reads it as usual.
room.setState('host:round', { n: 2, endsAt });

// A room takes a share of messages and bytes a second from each player, and the bigger the room the smaller the
// share (everything one player sends is copied to everyone else, so a room's work grows with the square of its
// players): up to 11 players, 60 messages, 20 presence updates and 128 KB a second; 16 players, 36, 20 and 128 KB;
// 30 players (the most a room seats), 15, 15 and 68 KB. Short bursts above that are fine. `room.budget` says what
// this room takes: { messagesPerSecond, presenceHz, bytesPerSecond }. The SDK
// keeps that budget for you instead of the room dropping messages silently: within it a message
// goes out at once; over it, messages wait in order (consecutive writes of one state key become
// one, since only the newest value counts), control messages (ready, start, kick...) still go
// straight out, and once more than about a second's worth is waiting the oldest messages are
// dropped so what arrives is fresh, with a console warning that says so. Send less: batch
// events, write state at 10-20 Hz and only what changed.

// 3. Private values: hidden information (a hand of cards, a role, the word being drawn). Only the player it's about
//    and the host receive it, and the room keeps it: a reload or a new host doesn't lose it, and no other player's
//    page ever has it (room state and messages go to everyone, so never put a secret there).
room.setPrivate('hand', cards);                // yours: up to 16 keys, 4 KB each, 8 KB together; null deletes
room.private.hand;                             // yours, as the room has it (also after a reload)
room.setPrivateFor(playerId, 'role', 'spy');   // host only: deal to a player (they, and you, can read it)
room.privateOf(playerId);                      // host only: read anyone's, to judge a guess or a vote
room.on('private', (key, value, playerId) => {});  // yours changed (the host hears everyone's)
//    Answers a player types (a sheet, a guess) are private values the player writes and the host reads with privateOf():
//    no scrambling in shared state, and a reload or a new host finds them. Send a message only for what is
//    time-critical (when they answered). A new host has everyone's private values before the `host` event fires.

// 4. Messages: one-off events. Not stored.
room.send({ type: 'boom', x, y });             // to everyone else
room.send({ type: 'hit' }, { to: playerId });  // to one player
room.on('message', (data, fromPlayer, at, matchTime) => {});  // at: when the room took it in, on ow.now()'s clock: the same
                                               // for everyone, so "who answered first" is fair (never the arrival time);
                                               // matchTime: the same moment on room.matchNow()'s clock (pauses not counted)

room.on('join', (player) => {});
room.on('leave', (player, kicked) => {});      // kicked: removed by the host or a vote
room.on('host', (hostId) => {});               // the host left; a new one took over
room.on('chat', ({ from, name, text }) => {}); // public overlay chat (direct and group
                                               // messages stay private to their people)
room.on('close', (reason) => {});              // 'replaced' | 'disconnected' | 'left' | 'moved' | 'kicked'
room.leave();

Matches

Start from the party template for any game with rounds, a lobby or turns: the onceworlds new_game tool starts a folder from it (it is also templates/party in the Onceworlds repo). It's a small complete game (Signal) that does everything below correctly (the platform's lobby and settings, reload-proof rounds, a hidden value that survives a change of host, match-time deadlines, unique round ids), with the reason beside each part. Change the game, keep the structure.

Party games, races, battles and any game with a lobby share one cycle, and the room keeps it for you so every player's page agrees on it (games that kept it by hand disagreed after a reload, a host change or a slow connection):

lobby ── start ──▶ starting (3 s) ── play ──▶ playing ── endMatch ──▶ lobby
                     │ someone un-readies                │ too few connected: paused, 2 min, then over
                     └──▶ lobby

What earlier games learned (each cost a real bug):

// With the platform's lobby there's no lobby code: the game only reacts to the match.
const room = await ow.rooms.join({ maxPlayers: 8, minPlayers: 3, lobby: 'card' });
room.on('matchstart', (match) => {
  const roster = room.participants;                    // spawn these, never "everyone with presence"
  if (room.isHost) hostBeginsRound(`${match.id}.1`);   // the host's page runs the rules
  else if (room.spectating) showWatching();
});
room.on('matchpause', () => stopTimers());
room.on('matchresume', () => startTimers());
room.on('matchend', () => showResults());              // results stay in room state; the platform's lobby is back under them
// In the host's page, when the rules say the game is over:
room.setState('host:results', standings);
room.endMatch();

A drop-in world, an obby or a sandbox needs none of this: it never calls startMatch, nothing in the room changes for it, and its players just play.

Data that outlives a session: ow.data

Three places that read alike. Pick by who must see it and who may change it:

Who changes it Who reads it How long
ow.data.player (the same object as ow.save) the player that player always
ow.data.server the room's host everyone in that server private servers and named rooms keep it after everyone leaves; a public server's ends with the server
ow.data.game anyone, only through what onceworlds.json declares every server of the game always
// Server data: a server's own world (a base, a town, a tycoon's progress). It comes with the room: reading needs no await.
const base = ow.data.server.get('base');                  // null when there's none
if (ow.data.server.canWrite) await ow.data.server.set('base', { walls, chests });   // host only: true once the room has it
await ow.data.server.delete('base');
ow.data.server.all();                                     // { key: value } of everything
ow.data.server.on('change', (key, value, from) => {});    // the host wrote it
ow.data.server.kept;                                      // true in a private server or a named room
await ow.data.server.reset();                             // a private server's owner: its world starts over, every page reloads

A key holds 16 KB of JSON; a server keeps 512 KB in 128 keys. A game keeps 5,000 kept worlds and 64 MB per game; a world nobody opens for 120 days is deleted. Write when something changes, never every frame: the platform stores it a few minutes after a change and when the server empties, so a crash loses nothing the room had. Other players can't write it: they send the change to the host (room.send), whose page checks it and writes. A friends game (rooms.join({ private: true })) is the same server every time a player starts it, so their world is there tomorrow, on any device. Its owner can start it over from the platform menu; the creator sees every kept world, and can start one over, in the dashboard's Data tab. On a page on its own (no platform), server data stays in that browser.

Game data: values every server shares (a community goal, a wall of messages, the fastest time ever, plots of a shared map, today's event). Any page can be a cheater's, so a key changes only by the operation its declaration in onceworlds.json allows, within its bounds, checked by the platform:

"data": {
  "donations": { "type": "counter", "max": 1000000, "add": [1, 100] },
  "meter": { "type": "counter", "max": 100, "add": 1, "reset": "daily" },
  "wall": { "type": "list", "max": 20, "bytes": 256 },
  "fastest": { "type": "record", "order": "min", "min": 5, "max": 600 },
  "plots": { "type": "map", "fields": 100, "perPlayer": 1 },
  "event": { "type": "value", "bytes": 1024 }
}
await ow.data.game.add('donations', 25);                 // the new total, or null when refused (and why on the console)
await ow.data.game.push('wall', { text: 'gg' });         // newest first; the oldest past "max" go
await ow.data.game.best('fastest', 41.2);                // { value, by: { id, name }, at, record }: record is true when it's yours now
await ow.data.game.claim('plots', 'p7');                 // true when it's yours; false when someone holds it
await ow.data.game.release('plots', 'p7');
await ow.data.game.set('event', { theme: 'pirates' });   // a value: only the host of the room you're in sets it
const total = await ow.data.game.get('donations');       // anyone: a few seconds old at most (null if it can't be read)

Up to 32 keys. Guests can use counters, lists and values; "guests": false keeps them out (records and maps are signed in only unless "guests": true). Each player makes up to 30 game data changes a minute, and text in lists and values is screened like chat. A deploy whose data is wrong fails and says what to write. Bounds are the only defence: a cheater can add the most add allows as often as the rate allows, so declare the tightest bounds the game needs and keep anything valuable off them. The dashboard's Data tab shows every key now and can clear one.

Secrets: calling AI and media APIs

The creator adds API keys in the dashboard (Environment → Secrets), each bound to one API host. Game code refers to a key as {{SECRET_NAME}} (any case) in the Authorization, x-api-key or x-goog-api-key header, or the key query parameter, and sends the request through onceworlds.fetch, which has the same shape as fetch. The platform fills in the key server-side, so it never reaches the browser. Only these hosts are reachable: api.anthropic.com, api.openai.com, generativelanguage.googleapis.com, api.mistral.ai, api.groq.com, openrouter.ai, api.together.xyz.

onceworlds.fetch also works as the fetch option of API SDKs. For Claude, use the official SDK:

import Anthropic from 'https://esm.sh/@anthropic-ai/sdk';

// The SDK only ever sees the placeholder; Onceworlds adds the real key.
// (dangerouslyAllowBrowser is safe here because no real key is in the browser.)
const claude = new Anthropic({
  apiKey: '{{ANTHROPIC_API_KEY}}',
  fetch: onceworlds.fetch,
  dangerouslyAllowBrowser: true,
});

// Small on purpose: every call is capped and priced (see the limits below).
const reply = await claude.beta.messages.create({
  model: 'claude-haiku-4-5',
  max_tokens: 1024,
  betas: ['server-side-fallback-2026-07-01'],
  fallbacks: 'default', // if Claude declines, Anthropic retries on a fallback model
  messages: [{ role: 'user', content: 'Greet the player in one short line.' }],
});
if (reply.stop_reason !== 'refusal') {
  const line = reply.content.find((block) => block.type === 'text')?.text;
}

Designing multiplayer for this game

Build the game the creator asks for. Everything below is a default for when they haven't said otherwise, never a reason to change their idea. When asked to "add multiplayer", first work out what playing together means in this game, then answer these questions and build the answers.

1. What kind of multiplayer is it?

2. Who plays together? Pick the server size the game feels best at (2 for duels, 6-12 for party games, 16-50 for worlds) and free-for-all or teams. Then decide between a public game (strangers in public servers; friends still get private servers from the platform) and a friends game (private: true: each player's own server, friends by invite), for games where strangers don't fit: a co-op campaign, a world or save that belongs to one player, a puzzle for two. Never both inside the game: the platform already offers the choice a public game needs.

3. Does a host make sense? A host is a player who runs the game for the others: starts it, picks settings, moves people between teams, removes troublemakers in private servers (room.kick). Rounds, matches and turns usually want one; drop-in worlds usually don't (there room.host only runs the game logic, and a private server's host might get a few toggles). In public servers the host is a stranger (whoever has been there longest when the role last moved): let them pick settings, but everyone (the host too) readies and the room starts the match itself with a countdown once they all have, so no stranger holds the lobby hostage, and never give them power over other players (removing someone takes a vote, room.voteKick). The room holds them to it: in a public server the host's setOpen(false) starts the match instead of closing the lobby, a setting change that stops a countdown counts like un-readying (twice in two minutes and the host waits a minute: cooldown), and a match's teams are fixed once it is under way. Private servers and named rooms give their host all of it.

4. When does play start and stop? For rounds and matches, the default is a lobby between games: results, then back to the lobby, where the host starts the next one (a player can take a break, the host can change settings, newcomers can settle in). The room runs the countdown (3 s, rooms.join({ countdown })) and the match's start and end (room.startMatch(), room.endMatch(), see Matches). Starting the next round automatically is right only when that's the game's point (a nonstop minigame hub with intermissions, a drop-in arena); then show the intermission timer. In private servers let the host end a game early.

5. What if there aren't enough players? Bots, a practice or solo mode, or a waiting screen that says how many players are needed and has an Invite button (ow.ui.showInvite()). A player alone should never face a dead screen.

6. What happens when people join or leave mid-game? Drop-in games let them play at once. Round games show newcomers what's happening (they are room.spectating) and let them play from the next match, or right away at a checkpoint if that's fair (the host calls room.admit([id]), e.g. at the start of a round or when a spectator taps Join). A player leaving never stalls the game: skip their turn, rebalance teams, recount "everyone is done". The room pauses the match by itself when fewer than minPlayers of its players are connected (room.on('matchpause'), room.matchNow() stands still) and ends it after two minutes, so a game doesn't reset because someone reloaded. When the host leaves, room.on('host') fires and the new host carries on without a reset, so keep the game's authoritative state in room state, not only in the host's memory.

A game whose host runs a live simulation (a co-op run, a battle) adds a few rules that earlier games learned the hard way:

7. What are the right timings? Rounds that start too early, or end before people finish reading, are the most common complaint from playtests. Start from these and let the host change the ones that matter:

One lobby, whatever the server. Players meet the same screens in every game, so keep to this unless the creator wants otherwise:

Details players notice

Before you ship a multiplayer game: break it with three players

Three browser windows (or three friends) find what one player never does. Each of these went wrong in a real game; try them all (the onceworlds playtest tool, or npx @onceworlds/cli playtest, runs the reload, drop, host-leaves and late-arrival checks for you, but not your game's own rules):

  1. Ready up. A lobby with a host where everyone else readies (see "One lobby" and Matches); joining never starts a game; nobody is dropped into a running round without picking their character or loadout (they're room.spectating, or admitted at a checkpoint).
  2. Reload every role mid-round: the host, the player whose turn it is, a bystander. Scores, the turn and each player's place must survive, and a reloaded page comes back to the same server. Keep match state in room state keyed by player id, never by connection, array index or the host's memory. A reloaded player is away then back (fresh: the new page has to be told what the old one knew) and a player who took too long is a leave then a join of the same id: don't delete their score on leave, and keep it for the reloading host too, who is not the host anymore when the page returns (the role moved on the moment they dropped, and it stays put).
  3. Cut the connection for ten seconds (airplane mode). The platform shows "Reconnecting…" and room.isHost is false until it's back, so only one client ever acts as the host; the others see that player as away. Pause what needs the network; don't restart, and don't wait forever for an away player (skip their turn after a few seconds, count only room.online).
  4. Two of three players leave, then come back. Below the minimum, the room pauses the match (matchpause): stop your timers (or keep them as room.matchNow() deadlines) and resume where it was on matchresume. The room ends the match after two minutes without them. Never reset to the lobby yourself because a count dropped: a reset that throws scores away punishes the players who stayed.
  5. Play three games in a row in one server. The third behaves like the first: counters, flags and votes are reset when a match starts (key them by match.id); the room clears the ready flags.
  6. Per-round UI state comes from the authoritative state, with a unique round id. A local "I've answered round 4" flag compared with a round counter breaks the moment the counter repeats or rewinds (a host change, a reconnect, a new game): one game showed the last round's answer in green with a disabled input for a whole round. Derive "answered" from what the host wrote (state.answers[me.id]), key it by a round id that never repeats (${room.match.id}.${round}), and reset every per-round field when the id changes.
  7. Judge with people, not word lists. If "is this answer valid?" or "is this drawing any good?" is a matter of opinion (a category game, a caption contest), let the players vote (an answer stands unless most of the others reject it; no vote counts as accepted, so nobody idle blocks the game) and show each result. Keep only mechanical checks (starts with the letter, not empty) automatic.
  8. Phones and tablets. Rotate the device; tap fast on your buttons (the platform stops pinch and double-tap zoom in the frame, so don't fight it); give every text field a font size of at least 16 px, or Safari zooms the whole page when it's focused; keep fixed HUD clear of the top-left corner where the platform's buttons sit.
  9. Frame time, not just frame rate. Try the game on a mid-range iPad, or throttle the CPU 4x in Chrome's Performance panel, with three players' worth of enemies and effects on screen. Cap the render scale (Math.min(devicePixelRatio, 2), less on phones), avoid allocating objects every frame, use a fixed timestep for movement and physics so 30 and 60 FPS play the same, and measure frame time and lower resolution and effects by themselves when it slips (raise them back slowly): ow.settings.quality, pixelRatio() and on('change') do exactly that and follow the player's own Graphics choice, so size canvases with ow.settings.pixelRatio(2) and let particles, shadows and post effects scale with quality. Honor ow.settings.reducedMotion. Draw remote players with room.presenceAt(id) (a moment behind, between updates: chasing the newest update with an ease makes their speed pulse, which reads as "the movement feels off") and never rubber-band the local player because of network data.
  10. Message budget. A room takes 60 messages and 128 KB a second from each player (fewer in a room of more than 11: 15 messages and 15 presence updates at 30 players, room.budget), and one message up to 16 KB. The SDK paces what you send to that (see above), but a game that sends more than it can be paced to still falls behind: send state at 10-20 Hz in batches, keep each batch under ~12 KB, and watch the console for "messages are waiting" and "were dropped" warnings. A crowded room is the test: three players in a fight, and for a big room node tools/roomload.mjs (in the platform repo) with 30 players. Pick the room size the game needs: the more players, the less each may send.
  11. Secrets. A hand, a role, the word being drawn: room.setPrivateFor / room.setPrivate, never room state or a broadcast (every page gets those, and opening the browser's tools shows them), and never only a page's memory (a reload or a new host forgets it). Reload the dealer, the drawer and the spy, and change the host: the secret is still there, and still hidden from everyone else.

Before you ship any game: try to break it alone

The bugs strangers find first are the ones nobody tried. Play your game like someone who has never read it, on a phone and on a laptop, and try each of these:

  1. Leave the world. Walk, jump, fall, get knocked and drive to every edge, corner, wall, water line and void, and try the same with a high speed or a low frame rate. A heightmap or grid that is larger than the visible island keeps a floor under the sky (players walked out onto the open sky in one game). Clamp positions to the playable area or respawn whoever leaves it, for players, bots, projectiles and the camera.
  2. Get stuck. Spawn and respawn inside a solid or on a hazard, wedge between two colliders, and make a required item or exit unreachable. Give every player a way out: a respawn, a stuck button, a reset.
  3. Find the dead ends. Every screen has a way forward, back and out; the pause menu can end; a countdown always fires; results lead somewhere. Press every button twice and at the same time.
  4. Play the first 30 seconds cold. Can a stranger name the goal and the controls without a manual? Put every control in controls in onceworlds.json (they show in the menu's How to play) and hint at the moment of need, once.
  5. Lose, win, restart, reload in the middle. Death should say what happened and offer an instant retry; a reload should land the player back where they were (onceworlds.save, load it on start).
  6. Every action answers. A hit, a pickup, a score, a refusal: something to see and to hear, readable and never spammy. Honour onceworlds.settings.reducedMotion.
  7. Draw every frame. A game that draws a backdrop or a lobby every second frame to save power runs at 30 frames a second and reads as broken. Draw every frame and let ow.settings.quality and pixelRatio() scale the cost. A third-person camera is tested against every solid (walls, roofs, big characters and props): it eases in when something comes between it and the player and never ends up inside anything.
  8. Run it, don't only parse it. node --check passes code that throws on load (a duplicate declaration, an undefined name, a raw line separator inside a regex literal): load the game in a browser and watch the console.
  9. Try the extremes. Zero, one and thirty players; a 24-character name; 99999 points; a hundred items; a phone in landscape; no network; the tab hidden and shown again.
  10. Dress the game page. onceworlds.json has a description, genre, icon, three or more thumbnails and controls (see "Make the game page art" above). After the first deploy, open onceworlds.com/games/<slug> on a phone and look at it as a stranger would. npx @onceworlds/cli doctor lists what is missing.

Patterns that work

Building common game types

These are patterns for common kinds of games, to adapt to what the creator wants. There is no server code: one player's browser, the host, runs the rules, and everyone else follows it through state and messages. This covers most multiplayer games; a determined player can still cheat, so keep rewards cosmetic.