SDK
What every game gets as window.onceworlds, and the platform's rules. AI tools read the same 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). A game page holds at most 5 thumbnails and videos together, and a game has at most 50 badges: a longer list fails the deploy. Images are PNG, JPEG, WebP or GIF up to 5 MB (a GIF at most 512×512 and 1 MB: it's shown as it is, never resized), 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", "store/thumb-3.png"], "badges": [{ "id": "first-star", "name": "First Star", "description": "Grab a star.", "icon": "store/first-star.png" }], "controls": [{ "keys": ["W", "A", "S", "D"], "action": "Move" }, { "keys": ["Space"], "action": "Grab" }], "help": "Grab stars. Whoever has the most when the timer ends wins." }controls(up to 12 lines: 1-4 short key names and an action of up to 40 characters) fill "How to play" in the platform menu, folded until a player opens it, the same place in every game. List every key or button the game uses (touch controls come fromow.controls), so players and screen readers find them without hunting through the game. Ahelpparagraph is still accepted but is no longer shown. 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, which use the same 5 slots as thumbnails) in the dashboard. Live deploys apply these fields only when they change inonceworlds.json, so dashboard edits last until then.engine(optional):"engine": "1"makes the game run on the Onceworlds engine, which the platform hosts. The platform then adds an import map before the game's scripts, soimport { createGame } from '@onceworlds/engine'(and'@onceworlds/engine/modules') works in a plain page or a zip upload with no build step."1"is the newest 1.x (fixes arrive without a redeploy),"1.2"the newest 1.2.x,"1.2.3"exactly that version (if it is ever withdrawn for a security fix, the release that replaces it). A deploy that names a version the platform doesn't have (or a withdrawn one) fails and says what to write; a game that doesn't sayenginegets no map, so a deploy whose code imports the engine without it is refused. The engine has its own guide, which replaces this one for games built on it (starting from a kit:npx @onceworlds/cli new my-game --kit <kit>): https://onceworlds.com/engine-guide.md. Everything below still applies to the SDK the engine sits on. An engine game can also be a scene project of Onceworlds Build, the visual editor at onceworlds.com/build: scenes as JSON, small scripts and a project.json, the files the editor opens and saves (npx @onceworlds/cli new my-game --template platformer; the engine guide's "Scenes and scripts" covers them).Make the game page art before the first deploy. The icon and thumbnails are what a player sees before the game: without them the game gets a plain colored placeholder, and few people click that.
store/in the starter holds placeholder art for Star Party: replace every file in it. With the onceworlds tools,playtestwithpicturessaves real ones from the game being played (store/icon.png,store/thumb-1..3.png, named inonceworlds.jsonif it has none): a good start, worth replacing with something chosen once the game looks right.- Icon: square, 512×512 PNG. It's shown as small as a list row, so one simple subject (the player, the main object) on a plain or softly shaded background, no words, and nothing important in the outer 10% (cards round and crop the edges).
- Thumbnails: 16:9, 1280×720 PNG or WebP, three to five (the page holds 5 thumbnails and videos in all, so 3 or 4 leave room for a video preview). The first is the cover on the game page, Discover and the home rows, so make it the best picture of the game being played: real gameplay with several things going on, not the title screen or a logo. The rest show other moments (a close-up, the multiplayer crowd, a result). Keep the subject in the middle and any title word short and large: small text is unreadable at card size. A GIF is never resized and stays at most 512 px, so use PNG or WebP for thumbnails.
- Where they come from: capture them from the running game: open it in a headless browser
(Playwright or Puppeteer) at 1280×720, wait for a lively moment and take a screenshot, then crop
the icon square around the main character. Or draw them with the game's own sprites or canvas code,
or render an SVG to PNG with a script. Commit them under
store/. - Don't use other games' characters or logos, platform buttons, browser chrome, or anything that looks like a login or an official notice (see "Keep it safe for everyone" below).
- Look at the result at 128 px wide: if you can't tell what the game is, redo it.
Publish: push to GitHub. The Onceworlds workflow (
.github/workflows/onceworlds.yml) deploys every push to https://onceworlds.com. The production branch (mainunless the dashboard orproductionBranchinonceworlds.jsonsays otherwise) 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. To test with several players alone, start a private server and open its invite link in other windows (a private window plays as a guest): public servers seat at most 3 players from one network, so extra windows land in other servers.npx @onceworlds/cli doctorreads the game's files for what games often ship with (no viewport tag, text fields under 16px,confirm(), localStorage, no lobby, keys with no touch controls, a missing icon, thumbnails or How to play): run it before pushing. A deploy that fails shows why in the Actions log and on the game's dashboard. Only pushes (and manual runs of the workflow) can go live: if you add other triggers (pull requests, schedules, releases), those runs only make previews.Connected through the onceworlds MCP tools (
read_guide,new_game,check,deploy_preview,playtest,game_status, and for engine gamesapi_lookup,recipe,search_assetsandscreenshot; the person runsnpx @onceworlds/cli loginonce)? Then there is no repo and no workflow:deploy_previewpushes a draft to the person's own account. They play it on https://onceworlds.com/create and publish it there; you can't. A draft is private to them until they pick who can play. Push after every meaningful change (only changed files upload).playtestplays the draft as three guests in a headless browser on their computer (the checklist under "Before you ship a multiplayer game" below, done for you: reloads, drops, the host leaving, a phone screen); run it afterdeploy_previewand fix what it reports before asking the person to play. It checks that the room behaves, not that your rules are right or fun: that is still yours to test.game_statusshows the errors the person's own plays hit.onceworlds.jsonneedsslug,nameanddescription. Everything else in this guide applies unchanged.Before the first deploy, the repo's owner logs in once to https://onceworlds.com with GitHub (for an organization's repo, any member can). Until then the deploy job fails with a link to log in.
Change
slugfrom the starter'sstar-party: that one is taken. Links and names for new games can't use Onceworlds, staff or sign-in words, or other platforms' names and currencies (login,support,roblox,robux,free-…), even spelled with lookalike characters. A deleted game's link is held for its owner for 30 days, and a game that never got a deploy is removed after a month.New games start private: only accounts with write access to the repo (and, for an organization's repo, the Onceworlds GitHub App installed on it), logged in, can play. To share the game, the owner opens https://onceworlds.com/dashboard, picks the game, and sets it to Unlisted (anyone with the link can play; the link is the game's address, not a secret) or Public (also listed on Onceworlds). Public games are listed at once when the repo owner's GitHub account is at least 30 days old or already has a listed game; otherwise they show "In review" until Onceworlds approves them, and play by link meanwhile. Players can report a game, and a listed game that several established players report is hidden until it's reviewed. Deploys never change any of this.
index.htmlmust be at the root ofdir. Ifpackage.jsonhas abuildscript, CI runs it first (with npm, pnpm, yarn or bun, going by the lockfile).Deployed files: HTML, JS (
.js,.mjs,.cjs), CSS, JSON, images, audio, video, fonts, 3D models, WebAssembly and shaders (.glsl,.vert,.frag,.wgsl). The deploy lists anything it skipped.
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
- Familiar beats clever. A game people already know (tag, musical chairs, an obstacle course, a race, hide and seek, a color-matching floor) needs no explanation: its name is the rules. Make it yours with a theme, a twist and polish, not with new rules to learn. A brand-new concept has to be just as clear in five seconds.
- The name says what you do. "Pass the Bomb" or "The Floor Is Lava" beats a one-word name nobody can decode.
- One sentence. If the game can't be pitched in one short sentence a friend would repeat, it's too complicated.
The first minute
- One tap to play. The title screen is the game's name, the game alive behind it, and one big Play button. Players are moving within 10 seconds of opening the game: no tutorial screens, no settings first, no manual.
- Show the goal, don't explain it. An arrow, a glowing target, the bomb over someone's head, the finish flag at the top. If words are needed, two to five of them at the moment they matter ("Stand on BLUE!", "Hold to run"), then gone. Teach each control at the moment it's first needed, once; show a hint again only to a player who seems stuck.
- The core action is fun in 30 seconds. Moving and doing the main thing on an empty map should already feel good: action, instant feedback, a reward, the next decision, every few seconds. If it isn't, no feature will save it.
While playing
- Always know the goal and the score. At any moment a player can answer "what am I trying to do?" and "am I winning?" from the screen: the goal, the time left and their place or score in big shapes and numbers.
- Short rounds, fast restarts. Rounds of 30 seconds to 3 minutes, a match of a few rounds with points, results, and the next match one tap away. Losing should cost seconds, not minutes: a player who is out keeps doing something (a ghost that floats around, watching the leader) and is back next round.
- Simple controls. Move plus one or two buttons, the same everywhere in the game. On phones the platform's stick and at most two or three buttons.
- Ramp the challenge. Easy at first, harder within each round (faster, fewer safe spots, less time); mistakes cost a little and never a long wait. A player who is behind can still catch up.
- Never alone. Fill empty seats with bots so a lone player gets the full game. Bots play like people: a reaction time, mistakes, a personality, never perfect.
- Make winning a moment. A 3-2-1-GO with sound, a fanfare and confetti, a podium with the winners' avatars, a personal best, a badge for a milestone.
Juice: every action answers
- Within a tenth of a second every action gets a sound and motion: squash and stretch on jumps and landings, a pop (a quick overshoot in size) when something appears, dust, sparks, confetti, floating "+3" numbers. Ease every movement; linear motion looks robotic.
- Scale it to the moment: a footstep is a puff, a hit is a spark and a bonk, a knockout gets a short freeze (60-100 ms) and
camera shake. Shake from a decaying "trauma" value (
shake = trauma², smooth sine offsets, never fresh random jitter each frame) on the camera, never on the player's body. Juice returns to rest; it never blocks input. ow.settings.reducedMotion: no shake, no flashes, gentler particles.- Sound for every action, and music that sets the energy (louder in play, softer in menus). A synthesized Web Audio sound is fine; silence is not.
Screens anyone can read at a glance
- Big: buttons at least 56 px tall on phones (44 at the very least), text at least 18 px, scores and timers huge.
- Few words: labels and icons, never paragraphs, how-to blocks, taglines or small grey captions. One thing per screen.
- Bright and clear: saturated colors on a contrasting background, white text with a thick dark outline over busy scenes, and a symbol beside every color that matters (color-blind players). When you restyle something, check every piece of text in it against its new background.
- Room to breathe: generous spacing; HUD in the top corners and centre, away from the platform's buttons (top left) and the thumbs (bottom corners).
- One art direction from the game's world (clean low-poly with good lighting, crisp vector, pixel art...) and one display font, used everywhere. Glassy cards, gradients and glows on everything, pill-shaped chips and emoji soup make a game look generic.
- A look for everyone. Aim at the polish of the games people already play (an arena or racing game, a party game, a battle royale): a confident style, modern type, real lighting in 3D. Babyish blobs and bubbly fonts make a game look like it's for toddlers and push most players away; cute works when it's stylish.
- Players are the characters: draw each with their avatar (
ow.player.avatarUrl) and name, mark "You" (an arrow or a ring), and give bots names and a look of their own.
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 friends
(or 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. - Your own menus are only about your game. The platform menu already has volume, graphics,
reduce motion, the player list, invite, friends, chat, report, fullscreen, Reload and Leave:
don't rebuild any of them. Draw what is yours: a title screen with one big Play button, a lobby
or character pick, results, and a pause card when
ow.on('pause')fires if the game has anything to pause. Every screen has a way forward and a way back. Buttons are real buttons: at least 44×44 px, text of at least 16 px, reachable by keyboard and touch, and showing their key when they have one. Say it in a label, not a paragraph. Put every control incontrolsinonceworlds.json, so "How to play" lives in the platform menu and your title screen needn't carry a manual. - The platform picks the server, not the game. Players choose before your
game loads: Play puts them in a public server, a private server is for friends
(from the game's page or the platform menu), an invite link or a friend's Join
button brings them into that friend's server, and a party follows its leader.
Your game calls
ow.rooms.join()once, as it starts, and plays in whatever server it gets (room.kindsays which). So don't build Solo / Online / Host / Join / Quick match / Party screens, server codes or server lists: playing alone is just being the only one in the server. - Don't build invite links or friend lists. The platform's Friends button
invites friends (they get a Join button wherever they are on Onceworlds) and
copies a link for everyone else. In your game, offer
ow.ui.showInvite()only where a friend is missing: an empty seat in a lobby, or a "needs 2 players" wait. - Don't build text chat. The platform overlay has it (players press
/; in team games#teamtalks to their team), and direct messages, group chats and the party's chat are in the platform's Friends panel. Listen toroom.on('chat')if you want chat bubbles in your game (games only receive public messages). - Text from other players is untrusted. Names, chat, room messages, presence and
shared state come from other people's browsers, and anyone can send anything.
Draw them with
textContent(or canvasfillText), neverinnerHTML, and validate the shape of every message before you use it: a chat line that ends up ininnerHTMLruns someone else's code inside the victim's game. - Keep it safe for everyone, children included. Onceworlds filters chat and checks names, but a game is responsible for what it shows. Never ask for a password, an email, a phone number or any personal detail, and don't draw login or "session expired" screens: those get games removed and creators banned. Names that imitate Onceworlds, its staff or well-known brands are refused. Players and games can be reported from the platform menu and the game's page.
- 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.- The game frame has no origin of its own (it's sandboxed, so games can't reach
each other or the platform). What that means for your code:
localStorage,sessionStorageanddocument.cookiedon't throw: they live in memory for the visit and are gone on reload. Anything that should stay (progress, settings, unlocks) goes throughonceworlds.save, which also follows the player to other devices. IndexedDB, the Cache API and service workers aren't available.fetch,XMLHttpRequest,import(), module scripts, fonts and audio from your own files work. Classic Web Workers from your own files work (new Worker('worker.js'), or a Blob worker); module workers ({ type: 'module' }) don't, so bundle the worker as a classic script or run the code on the main thread. Engines that need threads orSharedArrayBufferneed a single-threaded build.- Images from your own files are requested with CORS, so a canvas can read their
pixels (
getImageData) and WebGL can use them as textures. If you analyse audio withcreateMediaElementSource, setaudio.crossOrigin = 'anonymous'beforesrc. history.pushStatedoes nothing, so don't route with the address bar. The play page's query string does reachlocation.search(/play/<slug>?quality=high), except the platform's ownserver,join,deploy,watch,player,consoleandtester.
- Fullscreen: call
onceworlds.ui.requestFullscreen()(or the normalelement.requestFullscreen(), which the SDK forwards to the platform). - Guard browser APIs that phones lack or refuse. An exception inside your frame
loop stops the game with no message, and iPhone Safari is stricter than desktop
Chrome:
navigator.vibrate,Notification,screen.orientation.lockandnavigator.getGamepads()can be missing or throw there. Feature-detect them (navigator.vibrate?.(30)), wrap the ones that can throw intry/catchand stop calling one after it throws, and never let a per-frame update depend on an optional API. Play the game once in Safari on an iPhone (Xcode's Simulator is enough) before you publish: a game that only ever ran in Chrome often draws nothing there. - 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. 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'; Android locks the screen to it in fullscreen, on other phones one held the wrong way gets a "Rotate your phone" card with Play anyway, and desktop ignores it). 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. - Touch controls that feel right. Ask for
ow.controlswhen play starts and clear it (ow.controls.set(null)) on every screen that isn't play. Match them to the game: moving a character isstick: 'wasd'(or'arrows') with the game reading keys as it does on a keyboard; a game that needs the exact direction (aiming, steering, analog speed) usesstick: 'analog'and readsow.controls.stick; a puzzle, card or tap game needs no stick, just things to tap. The stick catches every touch in the lower-left 45% by 55% of the screen, so a game whose players also tap the world there (tap to move, tap to cast) asks forzone: 'corner', a 200 px square in the corner. Buttons are verbs ("Jump", "Fire", "Grab"), the main action first (it's the biggest and the easiest to reach), at most four; anything else goes in a menu or on a long press. Aim and look by dragging in the world or by tapping what you want, never with a hidden mouse, and give every hover or right-click action a tap (or a button). Every tappable thing of your own is at least 44×44 px with 8 px between neighbors, and your own HUD stays out of the lower corners and the top left. Thumbs cover the lower corners, so draw nothing the player must read there while the controls are on. Callow.controls.set()whenever the game's state changes: a button that keeps its id stays held through a new label or place, and one you take away is let go. Branch onow.controls.touch, not on screen size: a laptop with a touch screen and a tablet with a keyboard are both real. Test with a thumb on a phone or the simulator, held both ways. - Mouse look (pointer lock):
- When to lock. Lock the mouse only while the player is steering in the world, and only from a click inside the game.
- Panels free the mouse. Every panel with things to click frees the mouse when it opens and takes it back when it closes. That covers the title, lobby and host controls, inventory, shop, settings and results.
- No hidden buttons. Never leave a button on screen under a locked mouse unless its key is shown on it (
EnterStart). - Esc opens your menu. Esc is the one key browsers give players to get the mouse back, and it's also how they reach the platform's buttons. So treat any lost lock as Esc: open your menu, with Resume first; solo games pause. Take the mouse back on Resume or on a click in the world.
- Show the key. Show a small
Esckey hint while the mouse is locked. - A refused lock never pauses. Browsers refuse a lock that has no click behind it (one the game asks for as a run starts), one asked for within about a second of Esc, and any lock in a frame that can't have one. Only Esc, or a lock the player had and lost, opens your menu: a refusal leaves the mouse free with a small
Clickhint, the next click in the world asks again, and where the browser keeps refusing (SecurityError,NotSupportedError) the player drags to look. - Touch screens. Never lock; drag to look.
- Limits: 100 MB per game, 25 MB per file, 2,000 files, paths up to 200
characters. HTML files (
.html,.htm) are at most 10 MB each (the platform injects the SDK into them as it serves them, so keep scripts, styles and data in their own files where you can). A zero-byte file is fine. Across all deploys a repo owner can list 100,000 files a day (a 2,000-file game deploys about 50 times), so don't commit build output that changes thousands of files on every push.
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:
- Public game (the default: worlds, arenas, races, party games, battles).
Play puts each player in the fullest public server that still has room, and new
servers open as needed. Players are only matched with others who asked for the
same
mode,maxPlayersandteams(a room seats 2 to 30 players, 16 unless you say; asking for more gives 30). A public server seats at most three players from one network (a home or school). Friends (on one connection or not) can start a private server instead (from the game's page or the platform menu), and a page opened from its link or a friend's Join button joins that server. - Friends game (co-op runs and campaigns, a world that belongs to its player,
puzzles for two, a table of friends):
rooms.join({ private: true }). Every player plays in their own private server, alone until friends come in by invite, Join or party. A page opened from an invite link joins that server instead of making one. The game's page and menu then offer no public servers.
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_gametool starts a folder from it (it is alsotemplates/partyin 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
- Who starts. A private server or named room: its host, with
room.startMatch(), onceroom.canStart(enough players are connected and everyone else is ready). A refused start is never silent:room.on('error')and the console say why (not_ready,few_players,bad_phase...). A public server: nobody; the room starts the countdown itself when everyone connected is ready and at leastminPlayers(default 2) are here. A stranger who is the host can't hold it up or rush it. - Who plays.
match.participants, fixed when the countdown begins: everyone who was ready (and the host, in a private server). They stay in it if they leave. Anyone else in the room is a spectator until the next match, or until the host callsroom.admit([id])(admitting while the match waits for players can bring it back to the minimum: it goes on, which is how a watcher fills a seat). The room clears the ready flags and, unless you passstartMatch({ open: true }), closes the server to newcomers while it plays. The countdown has a "Not ready" button (a private server's host gets "Stop") for a player who changed their mind. - What is the same for everyone.
match.id(unique, key everything a match keeps by it:${match.id}.${round}is a round id that never repeats),match.seed(a random number to seed shared randomness),match.participants,room.matchNow()(match time: it stands still while the match waits, sodeadline = room.matchNow() + 30000in room state survives a pause and a host change without being rewritten). - When people go. A reload or a drop holds the seat (see the seat hold above). If fewer than
minPlayersof the participants are connected, the match pauses (matchpause: stop timers, show "Waiting for players") and goes on when they're back (matchresume); after two minutes it ends. The host role goes to one of the participants. - Who ends it. Your game does, when its rules say so: the host's page calls
room.endMatch()after the results are in room state. Everyone returns to the lobby (matchend) and the next Ready. The platform's lobby does not draw results: your game does, from room state (draw them onmatchendand keep them visible behind the lobby card, as the party template does). - Time in a match. A message's
atis the room's clock. During a matchroom.on('message', (data, from, at, matchTime))also givesmatchTime: how long the match had been played then, pauses not counted, the scale ofroom.matchNow(). "Who answered first" and a deadline kept asmatchNow() + mscompare with it directly, whatever pauses came between. - Public servers pool by
minPlayerslike they do by mode and size (the default, 2, is right for anything fun with two players, like a duel or a game of tag; ask for 3 or more where one stranger shouldn't decide, as in votes and hidden roles): a 3-to-8 player party game saysrooms.join({ maxPlayers: 8, minPlayers: 3 }), and a duelmaxPlayers: 2. A game that fills the match with bots saysminPlayers: 1: a lone player readies and starts (those servers are a pool of their own), and the match only pauses when nobody is connected. Friends need fewer than strangers (one stranger shouldn't decide a vote):minPlayersPrivate: 2lets two friends play a game strangers start at 3. - Who draws the lobby.
rooms.join({ lobby: 'card' }): the platform does. Players with their faces and ready checks, Ready (or Start for a private server's host, lit whenroom.canStart), "Waiting for Ann, Bo", the 3-2-1, a note for players who are watching and a "Waiting for players" cover when a match pauses. A game with no settings needs no lobby code at all. The card dims the game and takes its taps while it is up, so a game with buttons of its own in the lobby (solo activities, a loadout, a shop) doesn't use it.lobby: 'bar': your game draws its lobby (settings, a character picker) and the platform adds a Ready/Start strip at the bottom plus the countdown and the notes.lobby: 'game'(the default) draws nothing: useroom.match,room.setReadyandroom.startMatchyourself. - Settings without a settings screen. Declare what the host picks and the card draws it:
rooms.join({ lobby: 'card', settings: [{ id: 'rounds', label: 'Rounds', options: [1, 3, 5], default: 3 }, { id: 'time', label: 'Draw time', options: [{ value: 60, label: '60 s' }, { value: 80, label: '80 s' }] }] }). Up to 6 settings of 2-8 choices. The host changes them in the card (everyone else sees the values, and everyone readies again after a change), and the game readsroom.settings({ rounds: 3, time: 80 }): a choice the game didn't offer is its default, and during a match it's what the match started with, the same for everyone. So a game with a few options needs no lobby code at all.- Keep them few and static. Six at most, and fold related choices into one (a "Clock" of Quick / Normal / Long
that means different seconds in each mode beats three time settings). A default can't depend on public vs
private or on the device: every page has to agree on it before the host picks anything, so it is one fixed
value. A
'bar'game that draws its own settings changes them withroom.setSetting(id, value); two changes in a row are safe (the SDK ignores the room's echo of the older one while the newer is on its way).
- Keep them few and static. Six at most, and fold related choices into one (a "Clock" of Quick / Normal / Long
that means different seconds in each mode beats three time settings). A default can't depend on public vs
private or on the device: every page has to agree on it before the host picks anything, so it is one fixed
value. A
- A lobby that floats over the game. The bar (and the card) sit over your game's own lobby screens: keep about
88 px clear at the bottom of every lobby-phase screen and the top centre clear of the Watching note. A game that runs something of its own in the lobby (a
practice run, a daily) calls
room.hideLobby()while it runs androom.hideLobby(false)after: only the Ready and Start strip goes, a countdown, a paused match and the note for watchers still show, and it returns by itself when a match begins. The strip is on for every screen the game draws while the room is in its lobby (a title, a shop, a settings screen): hide it for the ones where Ready makes no sense. The "Watching" note is drawn at the top centre (just "Watching" on a phone): show watchers what matters (the letter, the word) in your own screen, not in the header. - Watchers. Latecomers watch until you let them in; the room lists who it left out at the start (they were here
and hadn't readied) in
room.leftOut(match.left), so a game can admit latecomers straight away (at the start of a round, and while a match waits for players) and let a player who was left out, and may be away from the screen, wait until they touch it (player.idle). Admit nobody at a match's first step: everyone the room chose is already in. A paused match still admits watchers: one taking a seat brings it back at once.
What earlier games learned (each cost a real bug):
- Derive the phase, don't keep one. The game's phase is
room.match.phaseplus one record the host writes, keyed bymatch.id, that the host page reconciles every tick. A reload or a new host then heals itself, and a stale record from the last match reads as "no game" (record.mid !== match.id). - Adopt on
matchresumetoo.adopt()waits forroom.running, so a player who becomes the host while the match is paused can't take over then: call it onmatchstart,host,reconnectandmatchresume, and from the host's ticker whenever the record isn't yours yet, or the match stands still forever after the pause. matchNow()is the only clock while playing (it is 0 outside a match, so a lobby warm-up usesow.now()).- Count seats, not screens. Presence says only what a player alone knows (they're on the title screen). Who is
away, watching or a participant comes from the room (
player.connected,room.spectating,room.participants): anything that resizes the world, deals a hand or seats a bot follows seats, so a reload changes nothing. - Open the door on
starting. A private match closes to newcomers, and invite links are refused while it is closed: callroom.setOpen(true)from thestartingevent (not from a frame loop) if friends may drop in. setSettingneeds the host and the lobby phase. The result of a vote taken during play is applied afterendMatch(). The lobby strip's hidden state (hideLobby) resets when the match changes: hide it again.- Secrets. A secret lives in the host's private values, and the host
also keeps it in memory, so a new host finds it (
privateOffills after a round trip). One bigger than a private value (4 KB each, 8 KB per player: a drawing) stays in room state sealed with a key kept in the host's private values, handing each reader only their key; publish the key at the reveal. - A reload forgets
localStorageandsessionStorage(memory only in the frame): what a reload must give back (a half-drawn page, a typed answer) goes toow.save, and is read when the page starts. - Join first. Call
rooms.joinas the page loads, before a heavy scene is built, so a reload doesn't miss its seat (the join waits for the platform's answer, and a scene built in one go right after the call holds the page long enough to delay it). A pointer-lock game needs its own click after the platform's Ready: the pause card can be its "Play". - Away is a state with a clock. Record when you first saw a player away; wait for a chooser once, extend their
clock once at time-up, stop waiting when the hold is over. In a thinking game an idle player isn't absent.
A new host writes
byin the state it takes over and re-saves the bookkeeping under its own id. - The platform's Ready/Start strip is outside your game. A tap on it doesn't unlock sound or pointer lock inside your
frame (iOS stays silent until the first tap in the game). After
matchstart, if you need either, ask for one tap in the game ("Tap to play"); a hint that stays until it is used is enough. - Guests see notices (Log in, badges) at the top centre for a few seconds: keep nothing vital there that only a notice-free moment reveals, and remember a phone held sideways has under 400 px of height.
- An intro of your own (role cards, a letter roll) means
countdown: 0and running it aftermatchstart. - Messages reach only the pages connected right now. Anything a player must still see after a reload, a dropped connection or a late load (a suggestion waiting for an answer, an invite to a mini-game, a vote in progress) is room state or a private value; a message is for what only matters live (a ping, an emote, a sound).
- No quiet windows on taps. Ignoring a "Next" for 600 ms after a page turn swallows real taps. Name the page or turn in the
request (
{ next: pageId }) and let the host ignore the stale ones; a key held down is one tap (event.repeat). - Every round shows its result, the last one too, before the final results. Skipping the last round's scores leaves players never seeing how the game was won.
- A page drawing its own live input is the truth for it. The drawer's canvas, the runner's position: don't rebuild them from the room's echo of what this page sent (it lags behind, and redrawing it every time stalls the page); take the room's copy only after a reload, before this page has added anything.
- A deploy updates the players in rooms. When a page opened after your deploy (or a rollback, or a new engine release for
"engine": "1") joins a room, the platform reloads the pages there that run the older version: at once in the lobby or for a player who is only watching, and when the match ends for its players (they see "Update ready"). The reload is the seat hold above (away, thenbackwithfresh, held 30 s), so room state, private values and the match stay. A game that never starts a match is reloaded at once: keep what a player has built in room state orow.save, and join first. - Check incoming state against itself, not your constants. Until a match ends, its pages can run two versions of your game: a page that rejects a round because it has 3 categories when its own code says 10 strands everyone. Validate types and sane bounds.
// 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)
counter:minandmax(0 and 1e12 unless you say),add(what one add may be: a number for 0 up to it, or[least, most]like[-10, 10]; 1 unless you say),reset("daily"or"weekly", UTC). Its value starts atmin.list: the newestmaxitems (up to 100), each up tobytesof JSON (up to 1024). Each item is{ value, by, at }.record: one best score, the highest or ("order": "min") the lowest, frommintomax, with who set it.map:fields(up to 1,000) a player can claim, at mostperPlayereach (up to 100).value: one JSON value up tobytes(up to 8 KB), set by a room's host.
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;
}
- Only generation endpoints work (e.g.
POST /v1/messages,POST /v1/chat/completions), and every call must use a secret. - Spend is capped for you, because any player can call your key from their
browser's devtools with any request. Each call generates at most 4,096 tokens
(the platform lowers the model's output limit, or sets one, and reasoning
tokens count toward it) and asks for one result at a time. Every call is priced
in units (its estimated tokens times a weight for the model tier: small models
1, mid-size 3, the biggest 10, the priciest 50) against daily budgets per
player, per guest network and per game (2,000,000 units for a game by default;
the creator changes it in Environment → Daily limits). Keep prompts short, set a
small
max_tokens, and use small models for frequent calls. - Limits: 30 calls a minute per player. Bodies up to 256 KB (16 KB for text-to-speech, 1 MB for file uploads), responses up to 10 MB. Responses arrive complete (no token streaming yet). Failed calls (a network error, or a 5xx or 429 from the provider) don't count against the daily limits.
- Refused (
onceworlds.fetchthrows): links to files or images (send the bytes as base64 or a data: URL), stored files, conversations and caches (send the whole conversation each time), server-side tools such as web search, code execution, file search and MCP (your own function tools work), and output limits that aren't numbers. 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.- Nothing a player types belongs in a provider's logs. The proxy turns OpenAI's storage off and leaves out hosts that keep every request, but OpenRouter and Gemini log prompts when the account turns logging on: don't, for a game players type into, and never ask players for passwords or personal details.
- 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 friends game, a turn order and a host who starts.
- Co-op runs and campaigns (roguelites, survival, puzzles for two): usually a friends game. The player's own progress drives it; friends drop in and help.
- 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) 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:
- One shared time base. A game with its own pausable clock still keeps night time as
room.matchNow() - basewithbasein room state; the host publishesbasebefore it admits anyone, so a pause and a new host agree. - Authority follows
room.isHost && room.connected, checked every frame. Freeze while offline, promote a new host from the state its page mirrored (or from a saved record if everyone reloaded), drop the world when demoted, and never simulate on a freshly reloaded page until it is the host. - "In the game now" is participant AND connected AND has a body.
match.participantsnever shrinks, so players who are away, watching or quit must not count for "everyone is down", revives, targets or team scaling. - Latecomers. A JOIN flag in presence plus
room.admitinside a window the game defines (say, until the second boss), and a mirror-only WATCH run after it. The host role can land on a watcher: a host outside the run hands it to a player still in it, and ends a run nobody is in. - Decide from what players say about themselves. A host that decides from presence alone races with the players' own pages (a host on the results screen handed the role away because a friend's presence hadn't caught up): have each player flag their state (playing, results, watching, joining) in presence and decide from that.
- Checkpoint per-player progress with
onceworlds.save, keyed bymatch.id, on events (a pick, a level) and every few seconds, and once more when the page is hidden (visibilitychange, which runs before the page is frozen: the shell sends what a game writes then straight away and lets it outlive the page). Don't rely onpagehidealone: a save made as a page unloads can be lost. Bank a finished run once, under an id the profile remembers, and clear or bank the checkpoint on every way a run can end (a countdown called off, a match ended first).
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:
- Starting belongs to the room (lobby, ready, a 3 s countdown), never to a timer the game runs on its own.
Don't stack a second countdown on the platform's: if the game has its own intro, set
rooms.join({ countdown: 0 })or draw the intro betweenstartingandmatchstart, timed tomatch.startsAt. - Typing and drawing clocks assume a phone: about 50% longer than you'd give a keyboard, and offer Quick / Normal / Long to the host. A category answer under 12 s or a guess under 8 s is expert-only.
- Picking (a word, a card, a spawn) gets about 15 s, and the clock starts when the choices are on the player's
screen, not when the round starts. Pause it while the chooser is
away. - Reveals and vote windows scale with what there is to read (about 1.5 s per item, at least 5 s) and end early when everyone taps Ready; once half have, give the rest 3 s. Never let one idle player cost the full window every time.
- Results and podiums stay until the host continues (a private server) or 30-45 s pass (public), and the host
can skip them. Six seconds is a flash. Then
room.endMatch(), and the lobby is back. - Between rounds a 3-4 s intermission with a visible timer, skipped when everyone is ready. Next rounds and the next match don't start themselves, unless the game is a nonstop hub.
- Someone who is
away(a reload, a drop) holds their place: skip their turn only after 15-20 s, and never shorten a round because someone left, they may be back in five seconds.room.matchNow()deadlines survive a pause. Someone who isidle(player.idle) is at the game's window but not at the game: give them their turn's full clock, then skip them as if they had run out of time, and keep them in the match. - Two players only is where griefing lives (one stranger's vote decides everything): matchmade party games
want
minPlayers: 3; a duel is a duel. - The host should control what matters to the game (rounds, time per turn, words, teams), start, skip and end, and remove players in a private server. Settings freeze when the match starts.
One lobby, whatever the server. Players meet the same screens in every game, so keep to this unless the creator wants otherwise:
- The title screen has one button (Play, Start, Race...) and never picks a server. Join as the game loads, so friends who follow a link or a party land in the same place.
- Round games wait in a lobby: who's here, the settings, Start. Nothing starts
until everyone has readied up, and joining a lobby never starts a game. The room
enforces it, so you only draw the screens (ready toggles with
room.setReady, a check on each avatar, "Waiting for Ann, Bo" fromroom.notReady). In a private server (and a friends game) the host picks the settings and Start lights up whenroom.canStart; it callsroom.startMatch(); everyone else sees a Ready toggle, then "Waiting for". A public server has no real boss: everyone readies, and the room itself starts a countdown once they all have (and at least minPlayersare here). It stops if someone un-readies. One idle stranger must not hold a public lobby forever: once at least half of the players here are ready (and enough to play),match.waitUntilsays when the rest are left out (45 s): they watch that match and ready up for the next. Private servers have no timers: the host waits, or removes someone (room.kick; a vote in public ones). The room clears every ready flag when a match starts and when it ends. If the host changes a setting after players readied, callroom.clearReady()so they see it before they agree. - Someone who joins while a game is running watches it (
room.spectating, with a clear label) until the next match, or until the host admits them: they never land in the middle of it without a chance to pick a character, loadout or team. Your roster isroom.match.participants(orroom.participants), never "whoever has presence". - Alone, the player still plays: bots, practice or the game's solo content, in
the same screens. Offer
ow.ui.showInvite()there (an empty seat, "Need 2"), and nowhere else. - Solo activities while waiting (practice, a daily puzzle) end when a game starts. In a private server with others waiting, the host doesn't get them.
- The platform's player list removes players (the host of a private server) and runs vote kicks (public servers). Don't rebuild them in the game.
- When the room closes, show one message and one button: 'kicked' → "You were
removed" and Play (a public server, or the player's own in a friends game),
'disconnected' → Rejoin, 'replaced' → "Playing in another tab" and Play here.
Each button just calls
rooms.join()again, with the same options.
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): nothing freezes (see the lobby list above).- The host checks every request (distances, cooldowns, guesses, scores); other players only ask.
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):
- 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). - 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
awaythenback(fresh: the new page has to be told what the old one knew) and a player who took too long is aleavethen ajoinof the same id: don't delete their score onleave, 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). - Cut the connection for ten seconds (airplane mode). The platform shows "Reconnecting…" and
room.isHostis 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 onlyroom.online). - Two of three players leave, then come back. Below the minimum, the room pauses the match
(
matchpause): stop your timers (or keep them asroom.matchNow()deadlines) and resume where it was onmatchresume. 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. - 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. - 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. - 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.
- 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.
- 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()andon('change')do exactly that and follow the player's own Graphics choice, so size canvases withow.settings.pixelRatio(2)and let particles, shadows and post effects scale withquality. Honorow.settings.reducedMotion. Draw remote players withroom.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. - 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 roomnode 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. - 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:
- 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.
- 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.
- 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.
- Play the first 30 seconds cold. Can a stranger name the goal and the controls without a manual? Put
every control in
controlsinonceworlds.json(they show in the menu's How to play) and hint at the moment of need, once. - 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). - 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. - 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.qualityandpixelRatio()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. - Run it, don't only parse it.
node --checkpasses 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. - 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.
- Dress the game page.
onceworlds.jsonhas adescription,genre,icon, three or morethumbnailsandcontrols(see "Make the game page art" above). After the first deploy, openonceworlds.com/games/<slug>on a phone and look at it as a stranger would.npx @onceworlds/cli doctorlists what is missing.
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(at most 50 in all); 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); a round is a match (see Matches: the room closes the server while it plays and opens it between), and the host can rebalance withsetTeamin the lobby. 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): a friends game
(
rooms.join({ private: true, maxPlayers: 6 }), withow.ui.showInvite()on empty seats). 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; the match's start closes the server. 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 (a ranked party fills at most half the 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. - Co-op runs (roguelites, survival, campaigns): a friends game. The host's
save picks where the run starts (their unlocks, their stage); each player keeps
their own unlocks and rewards in
ow.save. The lobby is the player's own camp: their squad, empty seats that invite, and the host's Start. Friends who arrive mid-run drop in (or watch until the next stage). - 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.