Onceworlds

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

  1. 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 as AGENTS.md, and an onceworlds.json. --list shows the kits. With the onceworlds MCP tools, new_game with kit does 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.
  2. Change it. Rules and numbers first, then art and sound, then the title and how it looks. Keep it playable after every step.
  3. Check it. npm install once (for the tests and types), then npx @onceworlds/cli check before 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.
  4. Try it. npx @onceworlds/cli dev plays the folder on a local platform (when you work on the platform itself), or push a draft with deploy_preview and play it on onceworlds.com/create. Open a private server and its invite link in two or three windows to be several players.
  5. Make the store art. npx @onceworlds/cli posters draws the game's game.poster() scenes into store/ (below).
  6. 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.

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:

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

The first minute

While playing

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):

Still yours:

Multiplayer with the engine

Assets

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

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

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

Gotchas found while building the kits