Engine guide
Build a game on the Onceworlds engine: 2D and 3D, physics, input, sound, UI and multiplayer in one package. AI tools read the same guide at onceworlds.com/engine-guide.md; every component and option is in the API reference. Start from a kit: npx @onceworlds/cli new my-game --kit arena-2d.
Workflow
- Start from a kit.
npx @onceworlds/cli new my-game --kit <kit>makes a folder with a complete, working game of one kind (the table below), this guide asAGENTS.md, and anonceworlds.json.--listshows the kits. With the onceworlds MCP tools,new_gamewithkitdoes the same. A kit already has a lobby, bots, touch controls, sound, a finished round and tests: change it into your game rather than starting from an empty file. - Change it. Rules and numbers first, then art and sound, then the title and how it looks. Keep it playable after every step.
- Check it.
npm installonce (for the tests and types), thennpx @onceworlds/cli checkbefore every push. It runs the game's tests, then plays simulated matches headless with a reloaded host, a dropped player, the host changing, a late joiner and garbage messages, and says what broke. It needs no browser and takes seconds. Fix what it reports before anything else. - Try it.
npx @onceworlds/cli devplays the folder on a local platform (when you work on the platform itself), or push a draft withdeploy_previewand play it on onceworlds.com/create. Open a private server and its invite link in two or three windows to be several players. - Make the store art.
npx @onceworlds/cli postersdraws the game'sgame.poster()scenes intostore/(below). - Publish. Push to GitHub (the Onceworlds workflow deploys every push) or publish the draft from the Create page. The platform guide's "Workflow" section has the details of repos, previews, visibility and review: https://onceworlds.com/agents.md.
The files
index.html loads main.js as a module; nothing else is needed
main.js import { createGame } from '@onceworlds/engine'; import { config } from './game.config.js'; createGame(config).start();
game.config.js export const config = { modules, systems, scene }; export const actions = { ... } (no side effects)
src/ or *.js components, systems, prefabs, UI views, bots
test/*.test.js headless tests with @onceworlds/engine/test
store/ icon and thumbnails (made by `posters`)
onceworlds.json { "slug", "name", "description", "engine": "1", "genre", "icon", "thumbnails", "controls", "badges" }
package.json devDependencies: @onceworlds/engine (types and tests), vitest. Not part of the game.
- Keep the game's
createGameconfig in a module with no side effects (game.config.jsat the top or insrc/) and export it asconfig(an object, or a function(page) => configwhen each player's page differs).main.jsstarts it; tests andonceworlds checkimport the same file, so what is tested is what players run. Export the action map asactionstoo andcheckcompares "How to play" with it exactly. "engine": "1"inonceworlds.jsonmakes the platform add an import map before your scripts, soimport ... from '@onceworlds/engine'and'@onceworlds/engine/modules'work in a plain page and a zip upload."1"is the newest 1.x (fixes and features arrive without a redeploy);"1.2"the newest 1.2.x;"1.2.3"exactly that version, forever. A deploy that names a version the platform doesn't have fails and says what to write. A game without the field gets no map (a game that bundles its own copy of the engine doesn't need one).- The engine's files come from onceworlds.com/engine/
/, one address for every game, cached for a year per version, and the physics WebAssembly (about 1 MB, only when a game uses physics) is a separate file that never changes. You never copy, host or import it yourself. Do not write your own import map for @onceworlds/engine: the platform's comes first. - Games never import three.js or Rapier. Everything is reached through the engine; the few members that expose a backend
(
render3d.native,physics.raw) are marked unstable: use them only when nothing else can do it. - Plain JavaScript is the default: only
.js,.mjs, HTML, CSS, JSON, images, audio, fonts, glTF, WebAssembly and shaders are deployed (a.tsfile isn't). Write TypeScript if you like and build it to JavaScript with a build script: the project'sdirinonceworlds.jsonis then the build's output (see the platform guide).
Pick the default path
Each kind of game has one well-worn path. Start from its kit and keep its choices until something forces a change.
| Kind of game | Kit | Draws with | Moves with | Camera | Shell |
|---|---|---|---|---|---|
| Top-down arena, brawler, tank game, twin-stick shooter | arena-2d |
Render2D, Sprite or Shape2D, Tilemap |
Physics2D, TopDown (dash) |
CameraRig.fitAll or .follow |
Flow.rounds, bots fill seats |
| Platformer, runner, obstacle course | platformer-2d |
Render2D, SpriteAnim, Tilemap |
Physics2D, Platformer2D |
CameraRig.sideScroll |
Flow.rounds (race) or Flow.coop (stages) |
| Party and minigames, last-one-standing, hide and seek | party-2d |
Render2D, Shape2D, Text |
TopDown or your own systems |
fixed camera2D |
Flow.rounds, several short rounds |
| Third-person 3D arena, battle, adventure | third-person-3d |
Render3D, Model or Mesh, Light.sun, Sky, PostFx |
Physics3D, Character3D |
CameraRig.thirdPerson |
Flow.rounds or Flow.dropIn |
| Racing, driving, kart | racing-3d |
Render3D, Mesh |
Physics3D, ArcadeVehicle |
CameraRig.thirdPerson or .follow |
Flow.rounds (laps), bots drive |
| Tactics, board and card games, puzzles | tactics-iso |
Render3D ortho or Render2D |
Grid, GridMover (A*, ranges) |
CameraRig.isometric |
Flow.turns |
Games the table doesn't name, by the nearest path:
- Survival and sandbox worlds, hangouts, social spaces: the third-person or arena kit with
Flow.dropIn(play at once, no rounds),Netfor the shared world,Savefor what stays between visits. - Shooters: the third-person kit with a
Collider3Dper hitbox andphysics.raycastfor bullets (hit scan on the host), or the arena kit in 2D. Bullets arerpccalls to the host, which validates and applies them. - Co-op runs:
Flow.coopwith checkpoints; late friends join at the next checkpoint. - Single-player games: the same kits without
Net;Flow.noneorFlow.dropIn. Playing alone is being the only player.
A game is modules (what the engine adds: Render2D, Physics3D, Input, Audio, UI, Feel, Net, Flow, Save...),
components (plain data with a schema), systems (functions over queries that run in stages), and a scene that spawns
what exists at the start:
import { createGame, defineComponent, defineSystem, t, Transform } from '@onceworlds/engine';
import { Render2D, Shape2D, Input, Audio, UI, Feel, Net, Flow, camera2D } from '@onceworlds/engine/modules';
const Coin = defineComponent('Coin', { value: t.u8(1) });
export const actions = {
move: Input.axis2d({ keys: 'wasd arrows', stick: true, pad: 'left', label: 'Move' }),
dash: Input.button({ keys: 'Space', touch: { label: 'Dash', big: true }, label: 'Dash' }),
};
const Collect = defineSystem({
name: 'collect',
stage: 'fixed', // 60 times a second, however fast the screen is
query: [Coin, Transform],
authority: 'host', // only the host's page runs it: the rules move with the host
run({ query, world, feel }) { /* ... */ },
});
export const config = {
space: '2d',
modules: [Render2D(), Input({ actions }), Audio(), UI(), Feel(), Net({ components: [Coin] }), Flow.rounds({ /* title, lobby, roster, round, scoring, results */ })],
systems: [Collect],
scene(world) { world.spawn([Transform(), camera2D({ height: 12, clear: '#10131c' })]); },
};
Go a layer down only when you must
Every default has a lower layer, and the lower layer is the same API the default is written with. Go down one step at a time and only for the thing that needs it; the rest of the game stays on the defaults.
| You need | Default | Go down to |
|---|---|---|
| A controller that feels different | Platformer2D, TopDown, Character3D, ArcadeVehicle with their options |
write a system that reads Intent and calls physics.moveCharacter(entity, desired); then raw Body velocities and applyImpulse |
| A look no preset gives | Material.standard/toon/unlit, PostFx.polished(), Sky.preset |
Material.custom (WGSL), PostFx({ passes }), then render.addPass |
| Your own screens | the standard Flow views (flow.title, flow.results...) with UI({ theme }) |
replace a view by its name, then ui.draw.* immediate mode, then ui.overlay HTML |
| Rules of who owns what | Net({ components }), owner: 'host', rpc, claim |
net.send and net.onMessage with your own validated messages |
| A different match shape | Flow.rounds, .turns, .dropIn, .coop with their options |
Flow.none() and drive room.startMatch, endMatch yourself, following the platform guide's "Matches" |
| A sound that isn't there | Audio synthesized effects, audio.play(name), buses |
the Web Audio graph through audio.context |
| More than the engine does | everything above | window.onceworlds (the SDK) beside the engine; read https://onceworlds.com/agents.md |
When you do go down, the rules that kept the default safe still apply: simulation in fixed systems with time.dt and the seeded
world.rng('name') (never Math.random or Date.now in rules), drawing in render, host-only rules behind authority: 'host',
and every message from another page checked before it is used.
Make it fun
Players find games in a grid of thumbnails, often on a phone, and leave within seconds if they don't get it. Make games a teenager and an adult both want to play, that a younger player still understands. These are defaults from games that worked and games that didn't; the creator's idea always wins.
The idea
- Familiar beats clever. Tag, a race, an obstacle course, musical chairs, hide and seek, a color-matching floor: the name is the rules. Make it yours with a theme, a twist and polish, not new rules to learn.
- The name says what you do ("Pass the Bomb"), and the game fits one sentence a friend would repeat.
The first minute
- One tap to play. The title is the game's name, the game alive behind it (
title: { scene }), and one Play button. Players move within 10 seconds: no tutorial screens, no settings first. - Show the goal, don't explain it. An arrow, a glowing target, a flag. Two to five words at the moment they matter, then gone.
- The core action is fun in 30 seconds, on an empty map: action, instant feedback, a reward, the next decision, every few seconds.
While playing
- Always know the goal and the score from the screen: the goal, the time left, your place or score, in big shapes and numbers.
- Short rounds, fast restarts: 30 seconds to 3 minutes, a match of a few rounds with points, results, and the next match one tap
away (
flow.dismissResults()/ the UI's Play again). A player who is out keeps doing something (spectate the leader, a ghost). - Simple controls: move plus one or two buttons, the same everywhere. On phones the stick and at most three buttons.
- Ramp the challenge: easy at first, harder within each round; mistakes cost seconds; the one behind can catch up.
- Never alone:
roster: { fill, bot }seats bots so a lone player gets the full game. Bots play like people (defineBotSystem,botProfile(skill): a reaction time, aim error, mistakes, a personality), never perfectly. - Make winning a moment: the countdown, a podium with the winners' avatars, a badge for a milestone. The standard Flow screens do it; the kits keep them.
Juice: every action answers. Within a tenth of a second every action gets a sound and motion: feel.squash(entity) on jumps
and landings, feel.particles.burst('sparks', entity), feel.floatText('+3', entity), eased tweens (never linear), feel.shake
and feel.hitStop(80) for knockouts (shake the camera, never the body), a synthesized sound for every action. Everything respects
the player's Reduce motion setting for you. Juice returns to rest and never blocks input.
Screens anyone can read at a glance. Buttons at least 56 px on phones, text at least 18 px, few words (labels and icons, no
paragraphs), saturated colors on a contrasting background with a symbol beside every color that matters, room to breathe, one
art direction and one font (the UI theme is one flat, bold style). Draw each player with their avatar and name (NameTag), mark
"You", give bots names and looks. The UI keeps clear of the platform's buttons and the thumbs by itself: don't fight it.
A look for everyone. Aim at the polish of games people already play: a confident style, modern type, real lighting in 3D
(Light.sun({ shadows }), Sky.preset, PostFx.polished()). Babyish blobs make a game look like it's for toddlers.
What the platform requires, and what the engine already does
The platform's rules are in https://onceworlds.com/agents.md ("Rules of the platform", "Multiplayer rooms", "Matches"). With the engine most of them are done for you. This is the list to check against, and the rules that are still yours.
The engine does (keep the defaults):
- One server, chosen by the platform.
Flowandgame.join()callrooms.joinonce as the game starts. Don't build Solo, Host, Join, Quick match, server codes or lists, invite links, friend lists, chat or volume controls: the platform has them. - Lobbies follow the rule: everyone readies up; a host starts private ones; joining never starts a game.
Flow.roundsshows the live lobby (players move about while the others get ready), the Ready/Start strip and the 3-2-1 countdown. Public lobbies start themselves. - Host changes and reloads. The match phase lives in the room (
room.match) plus one record the host keeps; a page that loads into a round resumes it, a new host continues from the newest state, and a paused match resumes under the new host. This holds only if the rules run behindauthority: 'host'(ornet.acting()) and the state that matters is replicated or in the room. - Ghost players. A reload or a dropped connection holds the seat (15 and 30 s); the engine follows
connectedfor you. - Touch controls. The action map becomes the platform's stick and buttons while play is on, and goes away on every other screen.
The mouse-look rules (lock only from a click in the world, panels free it, Esc opens your menu, a refused lock never pauses,
touch never locks) are in
Input.pointer({ lock: 'while-playing' })andinput.lock. - Safe zones. The UI stays out of the top left 130 x 56 (the platform's menu and chat), the lobby's Ready strip, the thumbs' corners and the notch. Don't put your own HUD there.
- Reduce motion, Graphics quality, volume and mute come from the platform's menu through
render.quality,render.reducedMotionand the audio buses. - Saves and records.
defineSave/Savewrite throughonceworlds.saveafter a pause and when the page hides, never every frame, with versions and migrations. Badges, leaderboards and the stats a match reports are declared once inFlow(progress).
Still yours:
controlsinonceworlds.jsonlists every key and button so "How to play" in the platform menu is right.onceworlds doctor --fixwrites it from the action map (controlsFromActions(actions)is the exact function); keep the action map and the list in step.- Text from other players is untrusted. Names, chat, room messages and shared state come from other browsers. Draw them as text
(
Text,UI.label,ui.draw.text,ui.overlay.el({ text }): there is noinnerHTMLto misuse), and validate every message you send withnet.send:rpcandclaimcheck their arguments against a schema for you, which is why they are the default. - Never ask for a password or personal details, never draw login or "session expired" screens. Names that imitate Onceworlds, its staff or brands are refused. Players and games can be reported.
- Network. The game loads only its own files, the hosted engine, and the CDNs cdn.jsdelivr.net, cdnjs.cloudflare.com,
unpkg.com, esm.sh, ga.jspm.io (and Google Fonts). Other APIs are blocked: use
onceworlds.fetchwith a secret for AI and media APIs, never a key in game code (see the platform guide, "Secrets"). - The game frame is sandboxed.
alert,confirm,prompt, popups, navigation, clipboard writes and service workers are blocked;localStorageand cookies live in memory and vanish on reload (useSave); module workers don't work (classic ones do); noSharedArrayBuffer;history.pushStatedoes nothing. The engine already avoids all of them. - Phones. Guard browser APIs that phones lack (
navigator.vibrate,Notification,getGamepads): feature-detect ortry/catch. Keep text readable at 360 px, handleresize(the engine's renderers do), and play the game once in Safari on an iPhone (Xcode's Simulator is enough): a game that only ever ran in Chrome often draws nothing there. Import maps need Safari 16.4 or newer, so an iPhone older than that can't run an engine game. - Limits. 100 MB per game, 25 MB per file, 2,000 files, paths up to 200 characters, HTML up to 10 MB. Rooms seat at most 30
players, and the more players, the less each may send (
room.budget): the replication budget (Net) keeps a full room smooth. - Game page art before the first deploy (below), and a real
genre,descriptionandcontrols.
Multiplayer with the engine
- An entity has an owner.
world.spawn([...], { owner: me.id })makes a player's page simulate it and write its components;{ owner: 'host' }is for the host's rules (bots, pickups, a ball). A component withnet: { owner: 'host' }is written by the host even on a player's entity (health, score): clients can't award themselves anything. - Replicate what others must see, nothing else.
defineComponent(name, schema, { net: { replicate: true } })sends only changed fields, quantized by each field'sstep, batched inside the room's budget, drawn about 100 ms behind between two updates. Usepredict: trueon the owner's movement for instant feel; the host corrects it smoothly. Don't replicate what is derived. - Actions are calls.
net.rpc('fire', { args: { angle: t.f32(...) }, to: 'host', rate: 10, validate, run }): arguments are checked on arrival, a bad call is refused and never run, a player can only call so fast. Contested things ("two players reach one chair", a pickup) arenet.claims: the host settles them by the room's clock. Never trust a client's word about damage, score, position of someone else, or time. - The host is a page. It can reload, drop or leave. Rules behind
authority: 'host'follow the role at once; entities the host owns are kept in the room and come back with a new host. The engine tests exactly this (below): runcheck. - Time and randomness. Rules use
time.dtandworld.rng('stream')(seeded from the match), shared deadlines use the match clock (world.time.match, paused with the match), neverDate.now()orMath.random()in rules. - 30 players. Per-entity update rates of 10 to 20 Hz, interest by distance (
Net({ focus })),priorityon what matters.
Assets
- Formats: glTF and GLB (Draco or Meshopt), KTX2, PNG and WebP, Opus and MP3, Tiled and LDtk maps, JSON, WebAssembly, WGSL.
Preload per scene with
assets.add(name, { type, url })andawait assets.load(...); show progress on the title. - No art files? Build it. Procedural geometry (
Mesh.*), gradient and pattern textures, noise, and the synthesized sound effects need no files and load instantly. A well-made flat style fromShape2DandMeshbeats borrowed art. - Keep the first screen under 3 seconds on a phone: the title draws first, with the heavy assets loading behind it. Total under the platform's 100 MB; keep a 3D game under about 500 MB of memory.
- Other games' characters, logos and platform buttons are never fair game; look at the result at 128 px wide.
Test it without a browser
The engine runs with no DOM and no GPU. @onceworlds/engine/test is how you (and the AI tool building the game) check a game in
seconds.
// test/match.test.js
import { simulate } from '@onceworlds/engine/test';
import { expect, test } from 'vitest';
import { config } from '../game.config.js';
test('a full match ends with everyone ranked, through a reloaded host and a dropped player', async () => {
const run = await simulate(config, { humans: 2, bots: 2, seed: 7, minutes: 3, chaos: ['reload-host', 'drop-player'], latency: [10, 60] });
expect(run.errors).toEqual([]);
expect(run.ranking).toHaveLength(4);
});
onceworlds checkdoes the same for you with a set of chaos runs, plusnpm test. It is the gate before a push.- Chaos scenarios are the bugs real games had:
reload-host,reload-player,drop-player,host-change,pause-during-host-change(the host pauses the match, the role moves, the new host resumes it),late-joiner,garbage-messages(wrong shapes, other players' entities, floods, junk over shared records) andhost-leaves. Same seed, same run: a failing seed is a bug report. Add your own:{ name, at, run(env) }. - Test the rules, not the pixels. Step the game (
game.step(n),game.run(seconds)) and assert on components and resources: that a hit takes health, a pickup scores once, a round ends and ranks, a save round-trips, a bot reacts. Drive input withinput.setAxis('move', 1, 0)andinput.tap('jump'); replay a recorded run withinput.play(recording). - Controllers have exact numbers (a jump reaches
jumpHeight), the physics is deterministic, and UI layouts can be checked withui.preview(tree, { width: 390, height: 844, touch: true })(buttons at least 56 px, nothing off screen). - Before you ship: break it with three players (reload the host, close a window, let the match pause), and play it once alone
from the title to the results. The platform guide's checklists ("Before you ship a multiplayer game", "Before you ship any
game") still apply;
checkautomates most of the first.
Store art: posters
game.poster(name, setup) registers a staged scene for the game page. Opening the game with ?poster=cover draws that scene
instead of the game, at an exact size and a pixel ratio of 1, frozen and deterministic. npx @onceworlds/cli posters opens your
latest draft that way and saves a picture of each into store/<name>.png, naming icon and the 16:9 pictures in
onceworlds.json when it has none.
game.poster('cover', (p) => { // 1920 x 1080 by default; 'icon' is 512 x 512, 'badge-*' 256 x 256
p.camera({ height: 10, clear: '#203040' });
p.world.spawn([Transform(), Shape2D({ shape: 'circle', radius: 2, fill: '#ffd23f' })]);
feelOf(p.world).particles.burst('confetti', { x: 0, y: -4 });
p.settle(0.8); // simulate a little first, with the seed fixed
});
- Icon: one simple subject (the player, the main object) on a plain or softly shaded background, no words, nothing important in the outer 10%. Thumbnails: three to five, the first (the cover) the best picture of the game being played: real action with several things going on, the subject in the middle, any word short and large. Not the title screen, not a logo.
- Compose the scene on purpose: spawn the characters in a pose, set the camera, burst the particles,
settlea moment. Look at the result at 128 px wide: if you can't tell what the game is, redo it.
Mistakes the engine's errors will tell you about
The errors say what went wrong, where, and the line that fixes it. The usual ones:
| You wrote | What to do |
|---|---|
import ... from '@onceworlds/engine' and the page fails to load |
"engine": "1" in onceworlds.json (doctor warns about it) |
world.spawn([Health]) |
components are called: Health() or Health({ hp: 5 }) |
Math.random() or Date.now() in a rule |
world.rng('name') and time.dt / time.match: the page that replays or takes over must agree |
| a rule that changes shared state on every page | put authority: 'host' on the system, or spawn with { owner: 'host' } |
a system that changes physics bodies in update |
do it in fixed, before 'physics2d' / 'physics3d' |
| shaking the player's transform | feel.shake moves the camera only |
| your own lobby, countdown or podium | use Flow and the standard views; replace one by name only for a different look |
| touch controls drawn by hand | Input({ actions }) sets the platform's; branch on input.device, not on screen size |
| a message handler that trusts its input | use rpc or claim (schema checked), or validate every field |
localStorage for progress |
defineSave (the frame's storage is gone on reload) |
What onceworlds check, doctor and the platform each look at
check: the game's tests, simulated matches with chaos (needsgame.config.jsandnpm install), and everythingdoctorfinds.doctor: no viewport tag, text fields under 16 px, dialogs,localStorage, keys with no touch controls, a missing icon, thumbnails orcontrols,Date.now()for shared time, an engine import with no"engine"inonceworlds.json, and "How to play" out of step with the action map.--fixwritescontrols.--strictfails on likely bugs, for CI.- A deploy checks the file types and limits, the
engineversion (and says which exist), the game page fields, and your repo.
Gotchas found while building the kits
Input.pointer({ drag: 'none' })never reports presses: use the default drag mode, or a button action, for taps.net.isMine(entity)is true for host entities on the host's page. Checknet.owner(entity) === me.idwhen you mean "my own character".- In
Flow.turns,FlowState.turn.meis true on the host during bot turns (the host plays them). Check whose turn it is by id. - Net only accepts a record from the page that created it. Spawn a player's own entities on that player's page, not on the host's.
- A kinematic body under a
Platformer2Dcharacter blocks walking sideways. Make moving platforms colliders without bodies.