onceworlds

API docs

The SDK every game gets as window.onceworlds, and the platform rules. AI agents read the same guide at onceworlds.com/agents.md.

Workflow

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

// Public variables from the dashboard (Environment tab): feature flags, tuning.
// Available immediately, change without a redeploy, and players can see them.
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).
// Save on meaningful events (level done, every few seconds at most), never every
// frame: each player gets about 2 writes a second before saves start failing.
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.
// 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).
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)
  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.touch;              // true while the player plays by touch (controls on screen)
ow.ui.setOrientation('landscape');   // or 'portrait'; the default 'any' plays either way

// 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

Matchmaking puts each player in the fullest public server that still has room and opens new servers as needed, so there's no limit on players per game. Players are only matched with others who asked for the same mode, maxPlayers and teams. Signed-in players can also start a private server from the game's page and send its link to friends: on a page opened from that link, rooms.join() (without a name) joins the private server, with no code needed.

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: 6 });     // new private server
const room = await ow.rooms.join({ name: 'lobby-7f3k' });      // a named room your game manages

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.host            // id of the longest-connected player
room.isHost          // true if that's you
room.state           // shared key/value object: read it any time
room.kind            // 'public' | 'private' | 'named'
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();  // opens the platform menu at its Invite button (copies the link)

// Host only:
room.setTeam(playerId, 2);   // move a player (room.on('team', (player) => {}))
room.setOpen(false);         // match started: matchmaking stops sending players here
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

// Anyone: vote to remove a player. It takes a majority of the others (at least
// 2 votes). 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 any room) queue together. The host
// calls this; everyone in the room lands in one server (one team, if it fits).
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). The host calls room.setOpen(false) when the match
// starts; from then on, players who leave still count as playing it. When it
// ends, EVERY player's game reports the same result; ratings change only when
// the reports agree (a quitter's report isn't needed).
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) => {});  // [{ id, rating, delta }]; player.rating too
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. Keep it under 1 KB.
room.setPresence({ x, y, hue, score });
room.on('presence', (player) => { player.presence; });

// 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) => {});

// Each player can send about 60 messages and 128 KB a second to a room (short
// bursts above that are fine); anything beyond is dropped, and a client that
// keeps flooding is disconnected.

// 3. 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) => {});

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

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}} in a header or the query string 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, api.elevenlabs.io, api.replicate.com, fal.run.

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

const reply = await claude.beta.messages.create({
  model: 'claude-opus-5',
  max_tokens: 16000,
  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), free-for-all or teams, and whether friends need private servers (they always have them through the game's page and invite links).

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): let them start the game and pick settings, but show a visible auto-start countdown so an idle host never blocks everyone, and never give them power over other players (removing someone takes a vote, room.voteKick).

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). A 3-2-1 countdown before play, timed with ow.now(). 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 and let them play from the next round, or right away if that's fair (a team game can drop them on the smaller team). A player leaving never stalls the game: skip their turn, rebalance teams, recount "everyone is done". 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.

Details players notice

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.