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
onceworlds.jsonnames the game:slug(its URL, permanent),name,description,dir(the folder to publish;"."or e.g."dist"after a build). It also fills in the game page:genre,icon(square, at least 128×128),thumbnails(16:9, at least 480 wide; the first is the cover) andbadges(square icons, at least 64×64). Images are PNG, JPEG, WebP or GIF up to 5 MB, with paths relative toonceworlds.json:{ "slug": "star-party", "name": "Star Party", "description": "Grab stars with everyone online.", "dir": ".", "genre": "Party", "icon": "store/icon.png", "thumbnails": ["store/thumb-1.png", "store/thumb-2.png"], "badges": [{ "id": "first-star", "name": "First Star", "description": "Grab a star.", "icon": "store/first-star.png" }] }Genres: Action, Adventure, Fighting, Obby, Puzzle, Racing, Roleplay, Shooter, Simulation, Sports, Strategy, Survival, Tycoon, Horror, Party, Other. The creator can edit all of this (and add video previews) in the dashboard. Live deploys apply these fields only when they change in
onceworlds.json, so dashboard edits last until then.Publish: push to GitHub. The Onceworlds workflow in
.github/workflows/deploys every push to https://onceworlds.com. Themainbranch goes live atonceworlds.com/play/<slug>; other branches get a preview URL (printed in the Actions log). To try a change with real multiplayer, push a branch and open its preview.Before the first deploy, the repo's owner signs in once at https://onceworlds.com with GitHub (for an organization's repo, any member can). Until then the deploy job fails with a link to sign in.
New games start private: only the repo's owner (for an organization's repo, its members), signed in, can play. Everyone else sees "This game is private". To share the game, the owner opens https://onceworlds.com/dashboard, picks the game, and clicks "Make public" in the Settings tab. Deploys never change this.
With a local Onceworlds platform running,
npx @onceworlds/cli devdeploys to it on every save and prints a URL that reloads itself. Open that URL again in another tab with&player=2(up to 9) to play as a second guest and test multiplayer in one browser (local platform only).index.htmlmust be at the root ofdir. Ifpackage.jsonhas abuildscript, CI runs it first.
Rules of the platform
- The SDK is injected for you. Every HTML page gets
window.onceworldsbefore your scripts run. Don't add a script tag for it. Plain JavaScript needs nothing installed. With TypeScript or a bundler (Vite, webpack),npm i @onceworlds/sdkandimport onceworlds from '@onceworlds/sdk'for types and autocomplete: it's the same object aswindow.onceworlds(and runs outside onceworlds too). - Keep the top-left ~130×56 px clear. The platform's menu, chat and invite
buttons sit there. If that's a bad spot, call
onceworlds.ui.setMenuPosition('top-right')(orbottom-left/bottom-right). - Don't build your own volume or mute controls. The platform menu has them and
applies them to all Web Audio and
<audio>/<video>automatically. - Don't build invite links. The platform's invite button copies a link that brings friends into the player's server (public or private).
- Don't build text chat. The platform overlay has it (players press
/, and type@nameto message one player or#team/#groupfor group chats). Listen toroom.on('chat')if you want chat bubbles in your game (games only receive public messages). - Network: the game can only load and fetch its own files plus these CDNs:
cdn.jsdelivr.net, cdnjs.cloudflare.com, unpkg.com, esm.sh, ga.jspm.io
(plus Google Fonts). Calls to any other API are blocked. Use the SDK for
multiplayer and saves, and
onceworlds.fetchfor AI and media APIs (see "Secrets" below). - Never put API keys in game code. Everything in the game is public. Keys
live in the dashboard as secrets and are added server-side by
onceworlds.fetch. - Uncaught errors are reported to the creator's dashboard (Overview → Errors) automatically. There is nothing to set up.
alert(),confirm(),prompt(), popups, page navigation, clipboard writes and service workers are blocked. Draw your own UI. The game only runs inside its Onceworlds play page; a direct link to a game file opens the play page.localStorageworks but stays on one device. Useonceworlds.savefor progress that should follow the player.- Fullscreen: call
onceworlds.ui.requestFullscreen()(or the normalelement.requestFullscreen(), which the SDK forwards to the platform). - Every game runs on phones and tablets too. On a touch screen the play page
goes fullscreen with the first tap (where the browser allows it) and draws the
movement controls you ask for with
onceworlds.controls: a thumbstick in the lower left and up to 4 buttons in the lower right, like Roblox. Prefer them to a joystick of your own, and keep your own buttons out of those corners while they're on. Size the canvas to the window and handleresize(players rotate their phones), keep text readable at 360 px wide, and make everything work by tapping (no hover or right-click). When the on-screen keyboard opens (a text field in the game), the game frame shrinks to the space above it, so handleresizethere too. If the game only works one way round, callonceworlds.ui.setOrientation('landscape')(or'portrait'). Start play from a tap inside the game (a title screen's Play button): iPhones only let sound start from a tap in the game itself, and taps on the platform's controls don't count. - Limits: 100 MB per game, 25 MB per file, 2,000 files.
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;
}
- Only generation endpoints work (e.g.
POST /v1/messages,POST /v1/chat/completions), and every call must use a secret. - Limits: 30 calls a minute per player and 2,000 calls a day per game. Bodies up to 1 MB, responses up to 10 MB. Responses arrive complete (no token streaming yet).
onceworlds.fetchthrows if the host isn't allowed, the secret doesn't exist, or the secret belongs to a different host. API errors (like 401 or 429) come back as normal responses.- It only works on the platform (previews and local platforms included), not when the page is opened on its own.
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?
- Drop-in world (hangouts, sandboxes, obbies, tycoons, .io arenas): players
come and go at any time and play right away. No lobby, no rounds, one pool of
public servers (
rooms.join()), private servers for friends. - Rounds or matches (party games, races, battles, minigames): players play a game together, see results, then play again. These need a lobby.
- Turns (board, card and word games): a table of friends, usually a private server, a turn order and a host who starts.
- Head-to-head (duels, 1v1 or 2v2, ranked): matchmaking by mode
(
rooms.join({ mode: 'duel', maxPlayers: 2 })), oftenranked: true. - Solo with a social layer: leaderboards, badges, shared world events and other players' ghosts or gardens, maybe without rooms at all.
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
- Names and avatars on lobbies, scoreboards and results (
room.players,ow.player.avatarUrl), a host marker and "You". Names come from the platform (guests pick one before their first room and can change it any time): never ask for a name yourself, and followroom.on('rename'). - Settings the host picks live in room state, so everyone sees them change.
room.on('close', reason): 'kicked' shows "You were removed" with a way to find another server; 'disconnected' offers to rejoin. Nothing freezes.- The host checks every request (distances, cooldowns, guesses, scores); other players only ask.
Patterns that work
- Movement: put positions in presence. Draw other players by easing toward their latest presence, since updates arrive at up to 20 Hz, not every frame.
- Authority: the host owns shared state (spawning, scores, rounds). Other
players send requests with
room.send(..., { to: room.host }), and the host validates them and writes state. Onroom.on('host'), the new host takes over.game.jsshows this with stars. - Your own events aren't echoed.
senddoesn't come back to you, andsetState/setPresenceapply locally right away. - Single-player games can skip rooms entirely. The platform then hides chat.
- Guests (not signed in) can play, save and join rooms, but can't chat, earn badges or post scores.
- Badges: award one where the achievement happens, once per session
(
game.jsawardsfirst-star). Add each one tobadgesinonceworlds.json; until a live deploy creates it,awardjust resolves false. - Outside the platform (
onceworlds.mode === 'standalone'), saves use localStorage, badges are remembered on the device, androoms.join()gives you a solo room, so the same code still runs.
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.
- Team battles (capture the flag, team deathmatch):
rooms.join({ mode, maxPlayers: 8, teams: 2 }). The host runs rounds in state (round,scores,endsAt: ow.now() + 180000), callsroom.setOpen(false)while a round is running andsetOpen(true)between rounds, and can rebalance withsetTeam. Positions go in presence, hits and shots as messages to the host, which decides and writes scores. - Party games (draw-and-guess, trivia, word games): public servers of 8-10
(
rooms.join({ mode: 'party', maxPlayers: 10 })) start on a countdown; private servers wait in the lobby for the host, who picks rounds, time per turn and word lists. The host rotates turns, checks guesses and awards points for speed. Late joiners play from the next turn; a drawer who leaves ends that turn. - Board and turn-based games (Monopoly-style): friends use a private server
(
rooms.join({ private: true, maxPlayers: 6 })plusow.ui.showInvite()). The host owns the board in state (turn,board,players); players send moves to the host, which validates and applies them. For games that last longer than a session, the host also saves the board withow.save.set('match-<room.id>', ...)and restores it when the table reopens. - Farming and idle games (Grow a Garden-style): each player's garden, coins
and inventory live in
ow.save, with timestamps fromow.now()(plantedAt), so plants keep growing while the game is closed. Other players' gardens can be shown by putting a small summary in presence. Shared events like shop restocks or weather come fromow.now()(e.g.Math.floor(ow.now() / 300000)as the restock number, used to seed the stock) or from dashboard variables (ow.env.WEATHER). Trades: both players send an offer to the host, which confirms, and each player updates their own save. - Real-time strategy:
rooms.join({ mode: '1v1', maxPlayers: 2 })orteams: 2for 2v2, thensetOpen(false)at match start. Run a lockstep simulation: players send commands to the host, the host sends everyone numbered turns (10-20 a second) with the commands for that turn, and every client runs the same deterministic simulation (seed randomness from state). Keep messages small. - Fast shooters and duels (Rivals-style):
rooms.join({ mode: 'duel', maxPlayers: 2, ranked: true }), ormode: '2v2', maxPlayers: 4, teams: 2; friends queue together withparty: truefrom a private server. Send your own position, aim and animation 20-30 times a second withroom.send(or presence), predict your own movement locally, and interpolate others about 100 ms behind. The host confirms hits and keeps score. At the end, every player callsroom.reportResult(...)for ratings; use leaderboards for totals and badges for milestones. - Solo tutorial, then multiplayer: run the tutorial without joining a room
and store
tutorialDoneinow.save. When it ends (or right away for returning players),rooms.join({ mode: 'main' }). - Lobbies and parties: gather players in a room (a private server for friends,
or a public room as a lobby), and when the host is ready,
rooms.join({ mode, party: true })moves everyone there into one matchmade server together. Handleow.rooms.on('moved', ...)on every player to pick up the new room.