# Engine core API

The reference for `@onceworlds/engine` (phase 1: the core). It is written to be read by AI tools as well as people: each thing has one obvious way, examples are short, and the errors at the end say what to type to fix a mistake. The design behind it is in [DESIGN.md](DESIGN.md).

Everything here runs in Node and in a browser. Examples marked `// @run` are executed by the test suite.

```ts
import { createGame, defineComponent, defineSystem, t, Transform } from '@onceworlds/engine';
```

## A game in one screen

```ts
// @run
import { createGame, defineComponent, defineSystem, t } from '@onceworlds/engine';

const Position = defineComponent('Position', { x: t.f32(), y: t.f32() });
const Velocity = defineComponent('Velocity', { x: t.f32(), y: t.f32() });

const Move = defineSystem({
  name: 'move',
  stage: 'fixed',                       // runs 60 times a second, however fast the screen is
  query: [Position, Velocity],          // every entity that has both
  run({ query, time }) {
    query.each((entity, pos, vel) => {  // pos and vel are views of the data, not copies
      pos.x += vel.x * time.dt;
      pos.y += vel.y * time.dt;
    });
  },
});

const game = createGame({
  systems: [Move],
  scene(world) {
    world.spawn([Position({ x: 0, y: 0 }), Velocity({ x: 1, y: 2 })]);
  },
});
game.start();                           // in a browser this also starts the loop
```

In a browser `game.start()` runs the loop. In a test or in Node, make the game with `headless: true` and move it yourself:

```ts
// @run
import { createGame } from '@onceworlds/engine';

const game = createGame({ headless: true, seed: 1 }).start();
game.step(60);                          // one second: 60 fixed steps
game.run(2);                            // two more seconds
expect(game.world.time.now).toBeCloseTo(3);
```

## Components

A component is a named schema. Define it once, at the top level of a file.

```ts
import { defineComponent, t } from '@onceworlds/engine';

export const Health = defineComponent('Health', {
  hp: t.f32(100),                       // a number that starts at 100
  max: t.f32(100),
  lastHitBy: t.entity(),                // another entity's id (0 means nobody)
}, { net: { replicate: ['hp'], owner: 'host' } });

export const Player = defineComponent('Player', { id: t.playerId(), color: t.u8() });
export const Stunned = defineComponent('Stunned');   // no fields: a tag
```

The name is how it shows up in errors, saves and the network, so it must be unique in the game. It starts with a letter and uses letters, digits and underscores.

### Field types

| Type | You read | Default | Notes |
|---|---|---|---|
| `t.f32(d?)` | number | 0 | 32-bit float |
| `t.i32(d?)`, `t.u8(d?)`, `t.u16(d?)`, `t.u32(d?)` | number | 0 | whole numbers; `validate` enforces the range |
| `t.bool(d?)` | boolean | false | |
| `t.vec2(d?)`, `t.vec3(d?)` | `Vec2Ref`, `Vec3Ref` | zeros | `.x .y .z`, `[0]`, `.set(x, y, z)`, `.copy(array)`, `.toArray()` |
| `t.quat(d?)` | `QuatRef` | `[0, 0, 0, 1]` | a rotation; `.x .y .z .w` |
| `t.color(d?)` | `ColorRef` | `'#ffffff'` | `.r .g .b .a` (0-255), `.hex`, `.rgba`, `.set(r, g, b, a?)`, `.unit(out)` for 0-1 floats |
| `t.mat4()` | `Mat4Ref` | identity | 16 numbers, column-major |
| `t.entity()` | entity id (number) | 0 | write `view.target = other.id` |
| `t.playerId(d?)` | string | `''` | what `room.me.id` is |
| `t.enum(['idle', 'run'])` | the string | the first | assigning anything else throws |
| `t.string(max, d?)` | string | `''` | `max` is checked by `validate` |
| `t.list(t.u8(), 8)` | array | `[]` | items are plain values; assign a new array to change it |
| `t.object<T>(d?)` | whatever you stored | `null` | free-form data, copied per entity |

Numbers take hints: `t.f32(0, { min: 0, max: 100, step: 0.01 })`. `min` and `max` are checked by `validate`; `step` is how far the network and saves may round the value.

Vec, quat, color and mat4 fields are read as small objects that write straight into the component, so `transform.position.x += 1` works. They are changed with `.set(...)`, `.copy(...)` or by field; they are not assigned (`transform.position = [...]` is a type error).

### Making component data

Calling a component gives data for `spawn` and `entity.add`. Leave out what you don't need.

```ts
Health({ hp: 50 });                     // max keeps its default of 100
Stunned();                              // a tag
```

### Options: `net` and `save`

`net` and `save` are stored with the component for the network and save helpers to read: `net: { replicate: true | ['hp'], owner: 'host' | 'owner', rate: 10 }` and `save: true | ['hp']`. The core never acts on them. Naming a field that doesn't exist throws.

### Checking, encoding, saving

```ts
// @run
import { defineComponent, t } from '@onceworlds/engine';

const Unit = defineComponent('Unit', {
  hp: t.f32(10, { min: 0, max: 100 }),
  pos: t.vec2([0, 0], { step: 0.01 }),
  kind: t.enum(['a', 'b']),
});

expect(Unit.validate({ hp: 50, kind: 'b' })).toBeNull();            // fine
expect(Unit.validate({ hp: 500 })).toMatch(/Unit.hp must be between 0 and 100/);
expect(Unit.validate({ hpp: 1 })).toMatch(/Unit has no field 'hpp'/);

const data = Unit.encode({ hp: 42, pos: [1.234, 5], kind: 'b' });   // [42, 123, 500, 1]
expect(Unit.decode(data)).toEqual({ hp: 42, pos: [1.23, 5], kind: 'b' });
```

- `Component.validate(value)` returns a message or `null`. Use it on anything that comes from another player or a save.
- `Component.encode(viewOrValues)` and `Component.decode(array)` give the compact form: one entry per number, fields in schema order, vecs flattened, fields with a `step` as whole counts of it.
- `Component.toObject(view)` is the component as plain data (vecs as arrays), safe to `JSON.stringify`.
- `Component.assign(view, values)` copies plain values into a held component.
- `Component.fields` lists each field with its kind, storage, `replicate` and `save` flags.

## Entities

```ts
// @run
import { createGame, defineComponent, t, Transform } from '@onceworlds/engine';

const Health = defineComponent('Health', { hp: t.f32(100) });
const Stunned = defineComponent('Stunned');

const game = createGame({ headless: true });
const world = game.world;

const hero = world.spawn([Transform({ position: [0, 0, 0] }), Health()], { name: 'hero' });
hero.add(Stunned());
expect(hero.has(Stunned)).toBe(true);
hero.remove(Stunned);
hero.get(Health).hp -= 10;              // get() returns the component to read and write
expect(hero.get(Health).hp).toBe(90);
hero.despawn();
expect(hero.alive).toBe(false);
```

- `world.spawn(list, options?)` makes an entity. `list` is a list of component data (`[Health(), Stunned()]`, and lists may nest), or one component on its own (`world.spawn(Health())`). If a component appears twice, the later one wins, which is how a prefab is overridden.
- `options`: `{ parent, name, owner, local }`. `owner` is the player whose page simulates the entity and `local` the only player who has it; the network layer reads both.
- `entity.add(...data)`: adds components. Adding one the entity has overwrites it (its other fields go back to their defaults).
- `entity.remove(...types)`, `entity.has(type)`, `entity.get(type)`. `get` throws when the entity lacks the component; check `has` first.
- `entity.despawn()` removes the entity and its children. It returns false if it was already gone, so despawning twice is fine.
- `entity.id` is a number you can store and compare. `world.entity(id)` gives the entity back, or `undefined` if it is gone. An id is never reused by a different entity.
- A held view (`const h = hero.get(Health)`) stays correct while the entity lives, and throws a clear error once the component is removed.
- Changing a despawned entity throws; check `entity.alive`.

### Prefabs

A prefab is a function that returns a component list. They compose by spreading.

```ts
// @run
import { createGame, defineComponent, t, Transform, type Prefab } from '@onceworlds/engine';

const Health = defineComponent('Health', { hp: t.f32(100) });
const Enemy = defineComponent('Enemy');

const Unit: Prefab<[number]> = (hp) => [Transform(), Health({ hp })];
const Boss: Prefab = () => [...Unit(500), Enemy()];

const world = createGame({ headless: true }).world;
const boss = world.spawn([...Boss(), Health({ hp: 450 })]);   // the later Health wins
expect(boss.get(Health).hp).toBe(450);
```

### Hierarchy and transforms

```ts
// @run
import { createGame, GlobalTransform, Transform } from '@onceworlds/engine';

const game = createGame({ headless: true });
const ship = game.world.spawn([Transform({ position: [10, 0, 0] })]);
const gun = game.world.spawn([Transform({ position: [0, 1, 0] })], { parent: ship });
game.step(1);
expect(gun.get(GlobalTransform).matrix[13]).toBe(1);
expect(gun.get(GlobalTransform).matrix[12]).toBe(10);   // it moved with its parent
expect(ship.children).toEqual([gun]);
gun.setParent(null);                    // detach: the transform now means "relative to the world"
ship.despawn();                         // would have despawned the gun too, had it stayed attached
```

- `Transform` has `position` (vec3), `rotation` (quat) and `scale` (vec3), relative to the parent. Units are metres, y is up, right-handed. A 2D entity lives on the xy plane and turns about z (`quat.fromAngleZ`).
- `GlobalTransform.matrix` is the entity's place in the world, kept up to date by the built-in `transforms` system in the `late` stage. Read it there or later in the frame. It is added to every entity with a `Transform`.
- `entity.setParent(parent | null)`, `entity.parent`, `entity.children`, `world.childrenOf(id)`. Loops are an error. Despawning a parent despawns its children unless you pass `{ children: 'detach' }`.
- A child follows its parent only if both have a `Transform`. A child whose parent has none is treated as a root.
- Don't add `Parent` yourself; use `setParent`.
- Turn the built-in off with `createGame({ transforms: false })`.

## Systems and queries

A system is a function that runs at a point in the frame over the entities a query finds. Keep state in components and resources, not in the system.

```ts
import { defineSystem } from '@onceworlds/engine';

export const Regen = defineSystem({
  name: 'regen',                        // unique; shown in errors
  stage: 'fixed',                       // default 'update'
  authority: 'host',                    // only on the page that runs the host's rules
  after: 'damage',                      // optional ordering within the stage
  query: [Health, { without: [Stunned] }],
  run({ query, time }) {
    query.each((entity, health) => {
      health.hp = Math.min(health.max, health.hp + 2 * time.dt);
    });
  },
});
```

### Stages

Each frame runs the stages in this order:

| Stage | Runs | Use it for |
|---|---|---|
| `input` | once | reading keys, pointers, the platform's controls |
| `fixed` | 0 or more times (60 a second) | the game rules, movement, physics. Use `time.dt` |
| `update` | once | animation, effects, UI. Use `time.frameDt` |
| `late` | once | camera rigs, anything that must follow the rest. The built-in `transforms` runs here |
| `render` | once | drawing. Blend with `time.alpha` |

`before` and `after` take system names (or the systems themselves) and apply within one stage. Names that aren't there are ignored, so a system may say "after physics" without requiring the physics module. A loop is an error that names the systems in it. Systems with no constraints run in the order they were registered.

### The context (what `run` gets)

| Member | What it is |
|---|---|
| `world`, `game` | the world the system runs in, and the game |
| `time` | the world's clock (see below) |
| `query` | the system's `query`, ready to loop |
| `queries` | the system's `queries: { name: [...] }`, each ready to loop |
| `events` | `events.emit(Hit, {...})` and `events.read(Hit)` |
| `rng(name)` | a named random stream |
| `removed(Component)` | entities that lost the component since this system last ran |
| `since` | the tick the system last ran at |

Modules add more (`input`, `audio`, `net`...) with `game.provide(name, value)`.

### Queries

A query lists components and, anywhere in the list, one filter object:

```ts
[Position, Velocity]                                   // has both; you get views of both
[Position, { with: [Enemy], without: [Frozen] }]       // also an Enemy, and not Frozen (no view of Enemy)
[Health, { changed: [Health] }]                        // written (or added) since this query last ran
[Position, { added: [Position] }]                      // gained since this query last ran
```

`world.query([...])` makes one of your own (make it once, in setup); a system gets its `query` and its named `queries` ready made.

```ts
// @run
import { createGame, defineComponent, t } from '@onceworlds/engine';

const Pos = defineComponent('Pos', { x: t.f32() });
const Frozen = defineComponent('Frozen');
const game = createGame({ headless: true });
game.world.spawn([Pos({ x: 1 })]);
game.world.spawn([Pos({ x: 2 }), Frozen()]);

const movers = game.world.query([Pos, { without: [Frozen] }]);
expect(movers.count()).toBe(1);
movers.each((entity, pos) => {
  pos.x += 10;
  // return false to stop early
});
expect(movers.first()?.get(Pos).x).toBe(11);
expect(movers.entities()).toHaveLength(1);       // allocates: for setup and tests
```

- `query.each((entity, ...views) => {...})`, `query.count()`, `query.first()`, `query.entities()`, `query.matches(entity)`, `query.removed(Component)`.
- The views are shared and move from entity to entity as the loop runs: use them inside the callback, don't keep them. To keep a component, call `entity.get(Component)`.
- You may spawn, despawn and add or remove components inside `each`. The loop works from the list of entities it started with, so nothing is skipped or visited twice, and entities spawned inside it are not visited until the next run. Don't use a component's view after removing that component.
- Don't call `each` on a query inside its own loop; make a second query.
- `added` and `changed` are relative to the query's own last run (a system's, for a system's query). A system's own writes never wake its own `changed` filter. A write through a view or a vec field counts; so does `entity.add`.
- Hot loops allocate nothing. `entity.get()`, `query.entities()` and `world.entities()` do (once per entity, or per call), so keep them out of per-step loops.

### Resources

One value per world (the score, the match settings). They use the same schemas.

```ts
// @run
import { createGame, defineResource, t } from '@onceworlds/engine';

const Score = defineResource('Score', { points: t.u32(), best: t.u32(5) });
const game = createGame({ headless: true, resources: [Score({ points: 0 })] });
game.world.resource(Score).points += 3;
expect(game.world.resource(Score).points).toBe(3);
game.world.setResource(Score({ points: 9 }));    // the rest of the fields go back to their defaults
```

### Events

Typed messages from one system to another, alive for the frame they were emitted in and the one after. Each system reads each event once.

```ts
// @run
import { createGame, defineEvent, defineSystem, t } from '@onceworlds/engine';

const Hit = defineEvent('Hit', { target: t.entity(), amount: t.f32() });

const log: number[] = [];
const Damage = defineSystem({ name: 'damage', after: 'shoot', run: ({ events }) => { for (const hit of events.read(Hit)) log.push(hit.amount); } });
const Shoot = defineSystem({ name: 'shoot', run: ({ events, time }) => events.emit(Hit, { target: 0, amount: time.step }) });

createGame({ headless: true, systems: [Damage, Shoot] }).step(3);
expect(log).toEqual([1, 2, 3]);
```

`defineEvent('Jump')` carries nothing: `events.emit(Jump)`. `Hit.validate(payload)` checks a payload from outside, like a component's `validate`. A system that runs before the emitter gets the event in the next frame.

## Time

Each world has a clock, `ctx.time` or `world.time`:

| Field | Meaning |
|---|---|
| `dt` | the fixed step in seconds (1/60). Use it in `fixed` systems |
| `frameDt` | seconds this frame took, times `scale`. Use it in `update`, `late`, `render` |
| `now` | simulation time in seconds (`step * dt`) |
| `step` | fixed steps taken |
| `alpha` | 0 to 1: how far the frame is past the last fixed step. Draw `lerp(previous, current, alpha)` |
| `match` | seconds of the platform's match so far, not counting pauses (0 outside a match) |
| `scale` | how fast this world's time runs: 1 normal, 0.2 slow motion, 0 frozen |

The simulation advances in whole fixed steps (`createGame({ hz: 60 })`). A frame runs as many as fit, at most `maxSteps` (default 5): after a stall the game slows down instead of racing to catch up. One frame never counts for more than a quarter of a second.

```ts
// @run
import { createGame } from '@onceworlds/engine';

const game = createGame({ headless: true, worlds: { menu: {} } });
game.world.time.scale = 0;              // hit-stop: the game world stands still...
game.step(30);
expect(game.world.time.step).toBe(0);
expect(game.worlds.get('menu')!.time.step).toBe(30);   // ...and the menu world runs on
```

## Randomness

Never call `Math.random()` in game rules. Ask for a named stream; the same seed gives the same numbers on every page and in every test.

```ts
// @run
import { createGame } from '@onceworlds/engine';

const a = createGame({ headless: true, seed: 42 }).world.rng('loot');
const b = createGame({ headless: true, seed: 42 }).world.rng('loot');
expect(a.int(1, 6)).toBe(b.int(1, 6));
```

`rng.next()` (0 to 1), `float(min, max)`, `int(min, max)` (both included), `below(n)` (0 to n-1), `chance(p)`, `sign()`, `pick(list)`, `shuffle(list)` (in place), `normal(mean, deviation)`, `fork(name)` (an independent stream that doesn't depend on how much you drew), `state` and `setState(state)` (save and restore the position). Streams are independent: asking for `rng('particles')` doesn't change `rng('loot')`.

The seed comes from `createGame({ seed })` (default: random, or 1 when headless). When the game is in a room and a match starts, the match's seed replaces it on every page (`game.reseed(match.seed)` happens by itself), so a match's random numbers agree everywhere.

## Worlds

A game has a main world, `game.world`, and may have more: a minigame inside a game, a minimap, the UI.

```ts
const game = createGame({
  systems: [Gameplay],
  worlds: { minimap: { space: '2d', order: -1, systems: [DrawMap], scene: (w) => {...} } },
});
const hud = game.createWorld('hud', { space: '2d-pixels' });   // or later
```

Each world has its own entities, resources, events, random streams, clock and systems. Worlds run in `order` (lowest first, ties in creation order) once per frame. `world.space` is `'3d'` (y up, metres), `'2d'` (the same on the xy plane) or `'2d-pixels'` (y down, units are pixels); cameras read it. `world.addSystems([...])` adds systems later; `world.setSystemEnabled(name, false)` turns one off.

## Modules

A module is a bundle of services and systems built on this public API.

```ts
import { defineModule } from '@onceworlds/engine';

export const Sparkles = () => defineModule({
  name: 'sparkles',
  install(game) { game.provide('sparkles', makeSparkles()); },   // before any world exists: ctx.sparkles
  world(world, game) { /* runs for each world, the main one too */ },
  systems: [SparkleSystem],                                        // added to the main world
  async init(game) { /* when the game starts; hold game.ready until it finishes */ },
  dispose(game) {},
});
```

Declare a service's type by merging into `SystemServices`: `declare module '@onceworlds/engine' { interface SystemServices { sparkles: Sparkles } }`.

## Assets

`game.assets` is a registry and a loader with no rendering in it. Loaders for `json`, `text`, `bytes` and (in a browser) `image` are built in; modules add the rest (`glb`, `audio`...).

```ts
// @run
import { Assets } from '@onceworlds/engine';

const files = { 'level.json': '{"tiles":3}' };
const assets = new Assets({ baseUrl: 'levels', fetch: (async (url: string) => new Response(files[String(url).split('/').pop() as keyof typeof files])) as typeof fetch });

assets.add('level1', { type: 'json', url: 'level.json', group: 'level1' });
assets.provide('noise', new Float32Array(4));          // a value made in code, no loading
await assets.preload('level1', (p) => p.fraction);     // all of a group, a few at a time, with progress
expect(assets.get('level1')).toEqual({ tiles: 3 });
```

- `assets.add(name, { type, url, group? })`, `addAll(record, group?)`, `provide(name, value)`, `loader(type, async (url, { fetch }) => value)`.
- `assets.load(name)` loads one (once, however often you ask), `preload(group?, onProgress?)` loads a group or everything, trying all files and then rejecting with every failure.
- `assets.get(name)` returns a loaded asset and throws, saying how to load it, when it isn't ready. `isLoaded`, `names(group?)`, `progress(group?)`, `unload(name)`.
- `createGame({ assets: { baseUrl, fetch } })` sets the base URL for relative paths and swaps the fetch (tests serve files from memory).

## The platform and rooms

The engine reaches the platform only through `game.platform`: on Onceworlds that is `window.onceworlds` through a thin adapter, opened alone it is a stand-in with one player, and in tests it is a fake room. All three look the same to a game.

```ts
const player = await game.platform.player();               // { id, name, guest }
await game.platform.save.set('progress', { level: 3 });    // saves
await game.platform.badges.award('first-win');
await game.platform.leaderboards.submit('wins', 12);
game.platform.controls.set({ stick: 'wasd', buttons: [{ id: 'jump', label: 'Jump', key: ' ' }] });
game.platform.settings.reducedMotion;                      // the player's menu settings
game.platform.now();                                       // the platform's clock in ms
```

### Joining a room

```ts
const room = await game.join({ maxPlayers: 8, minPlayers: 3, lobby: 'card' });   // the SDK's JoinOptions
```

`game.join(options)` joins (call it once, as the game starts) and then follows the room for you:

- `game.room` is the room (null before joining and after leaving). It has the SDK `Room` members the engine uses: `me`, `players`, `online`, `host`, `isHost`, `state`, `match`, `participants`, `spectators`, `running`, `matchNow()`, `settings`, `setReady`, `startMatch`, `endMatch`, `pauseMatch`, `admit`, `send`, `setPresence`, `setState`, `setPrivate`, `on(event, fn)`... (the type is `PlatformRoom`). Everything in the guide's "Multiplayer rooms" and "Matches" applies unchanged.
- `game.isHost()` is true on the page that is the room's host and connected, and on every page with no room. A system with `authority: 'host'` runs only there, so when the host drops or changes, the rules move with it at once.
- `world.time.match` is the match clock in seconds, standing still during a pause.
- A match's seed reseeds every random stream when the match starts (`starting` or `matchstart`, whichever the page hears first).

The lobby, ready-up and host-change rules of the guide are not the core's job; the Flow and Net modules (a later phase) apply them. Until then, follow the guide's "Matches" section when you handle `room.on('matchstart' | 'matchresume' | 'host' | 'reconnect', ...)` yourself.

## Testing without a browser

`@onceworlds/engine/test` runs games with no DOM and no GPU.

```ts
// @run
import { defineComponent, defineSystem, t } from '@onceworlds/engine';
import { simulate } from '@onceworlds/engine/test';

const Counter = defineComponent('Counter', { n: t.u32() });
const Count = defineSystem({ name: 'count', stage: 'fixed', query: [Counter], run: ({ query }) => query.each((_e, c) => void c.n++) });

const run = await simulate(
  { systems: [Count], scene: (world) => void world.spawn([Counter()]) },
  { seed: 7, seconds: 5 },
);
expect(run.errors).toEqual([]);
expect(run.world.query([Counter]).first()!.get(Counter).n).toBe(300);
```

### `simulate(config, options)`

`config` is a `createGame` config, or a function `(page) => config` that gets `{ index, id, humans, bots }`. Options:

| Option | Meaning |
|---|---|
| `seed` | seeds the game and the chaos (default 1): the same seed gives the same run |
| `seconds`, `minutes` | how much game time to play (default 10 s); time is virtual, a long match is fast |
| `humans` | pages (players) in one shared room (default 1); each gets its own game |
| `bots` | passed to the config as `page.bots` |
| `fps` | frames per second of game time (default 60) |
| `latency` | one-way message delay in ms, or `[least, most]` |
| `room` | `'public' \| 'private' \| 'named' \| 'solo'`; by default the first `join` decides |
| `join` | join this room as each game starts (leave it out when the game joins itself) |
| `chaos` | `'reload-host'`, `'drop-player'`, `'host-change'`, or your own `{ name, at?, run(env) }` |
| `until(run)` | stop early when it returns true; `onFrame(run)` runs after every frame |

It resolves to `{ game, games, world, pages, hub, frames, seconds, errors, phase, match, ended }`. `errors` is every error a game reported, as `"system 'x' in world 'main': message"`. A game with no `onError` of its own throws on the first error when you step it by hand (`createGame({ headless: true })`), so a failing test points at the line; `simulate` collects instead, and `expect(run.errors).toEqual([])` is the check.

### `fakeRoom(options)`

The room itself, for tests that drive several games by hand. Time moves only when you call `advance`.

```ts
// @run
import { createGame } from '@onceworlds/engine';
import { fakeRoom } from '@onceworlds/engine/test';

const hub = fakeRoom({ latency: 40 });
const ann = createGame({ headless: true, platform: hub.page('ann').platform });
const bo = createGame({ headless: true, platform: hub.page('bo').platform });

const joined = Promise.all([ann.join({ private: true, minPlayers: 2 }), bo.join()]);
hub.advance(200);                       // messages take time; nothing moves until you say so
await joined;
expect(ann.isHost()).toBe(true);
expect(bo.isHost()).toBe(false);

bo.room!.setReady();
hub.advance(100);
ann.room!.startMatch({ countdown: 1 });
hub.advance(1500);
expect(bo.room!.match.phase).toBe('playing');
expect(bo.seed).toBe(ann.seed);         // the match's seed reached both
```

- `hub.page(name, id = name)` is one browser page: `page.platform` goes into `createGame({ platform })`.
- `page.drop(ms)` cuts the connection (messages in flight are lost, the seat is held, it reconnects by itself), `page.reload()` is a page reload (a fresh platform, the seat held, saves kept: start a new game on it and join again), `page.leave()`.
- `hub.advance(ms)`, `hub.now()`, `hub.hostId`, `hub.connectedIds`, `hub.currentMatch`, `hub.roomState`, `hub.setHost(id)`, `hub.forceEnd()`.
- The room follows the platform's documented rules: ready flags, a host who starts private matches, public lobbies that start themselves, the countdown, participants fixed at the start, a pause when too few are connected (and an end after two minutes), seat holds of 15 s for a reload and 30 s for a drop, a host who keeps the role while connected, private values, lobby settings frozen at the start. It does not pace messages (games can send as fast as they like) or smooth `presenceAt`.

### Chaos

A chaos scenario breaks something a real player would: reload the host's page, drop a connection, change the host. `simulate({ chaos: [...] })` runs each at a seeded moment (between 20% and 80% of the run, or `at: seconds`). The environment a scenario gets has `hub`, `pages`, `games`, `rng`, `seconds`, `hostIndex()`, `reload(i)` and `drop(i, ms)`. Add entries to the exported `chaos` record to use your own by name.

## Math

Plain functions on plain arrays (and typed arrays, and component vec fields). Each writes into its first argument and returns it, so a hot loop allocates nothing.

```ts
// @run
import { mat4, quat, vec3 } from '@onceworlds/engine';

const out = [0, 0, 0];
vec3.add(out, [1, 2, 3], [4, 5, 6]);                    // out is [5, 7, 9]
vec3.scaleAndAdd(out, out, [0, 1, 0], 2);               // out + [0, 1, 0] * 2
const turn = quat.fromAngleZ([0, 0, 0, 1], Math.PI / 2);   // a 2D rotation
const m = mat4.compose(mat4.create(), [1, 2, 3], turn, [1, 1, 1]);
expect(mat4.transformPoint([0, 0, 0], m, [1, 0, 0])[1]).toBeCloseTo(3);
```

- `vec2`, `vec3`: `create clone set copy add sub mul scale scaleAndAdd negate dot cross lengthSq length distanceSq distance normalize clampLength lerp equals` (vec3 also `min max transformQuat angleBetween`; vec2 also `rotate perp angle fromAngle`).
- `quat` (`[x, y, z, w]`): `create identity set copy mul conjugate invert normalize fromAxisAngle fromEuler fromAngleZ angleZ rotateX rotateY rotateZ slerp nlerp rotationTo dot length equals`.
- `mat4` (16 numbers, column-major): `create identity copy mul invert compose decompose fromTranslation fromScaling transformPoint transformDirection lookAt perspective ortho equals`.
- Scalars: `clamp clamp01 lerp inverseLerp remap smoothstep wrapAngle angleDelta lerpAngle approach damp repeat nearly`, and `DEG2RAD RAD2DEG TAU`. Angles are radians everywhere.
- `vec3.add(position, position, step)` works directly on a component's field: `vec3.add(transform.position, transform.position, step)`.

## Mistakes and what the errors tell you to do

| You wrote | The error says |
|---|---|
| `world.spawn([Health])` | `Health is a component type, not component data. Call it: Health() or Health({ ... }).` |
| `Health({ hpp: 1 })` | `Health has no field 'hpp'. Its fields: hp, max.` |
| `entity.get(Health)` on an entity without it | `Entity 5 has no Health. Add it with entity.add(Health(...)), or check entity.has(Health) first.` |
| using a view after `entity.remove(Health)` | `Health is no longer on entity 5: it was removed, or the entity was despawned. Check entity.has(Health)...` |
| `view.mode = 'fly'` on an enum | `Unit.mode is "fly", but it must be one of 'idle', 'run'.` |
| `world.query([Health()])` | `A query lists component types, not data: write [Health], not [Health()].` |
| `query.each` inside its own loop | `This query is already running... Make a second query for the inner loop.` |
| two systems with one name | `Two systems are named 'x' in world 'main'. System names must be unique.` |
| `before`/`after` that loop | `These systems wait for each other in a loop: a, b. Remove one of their before/after lines.` |
| `entity.add(Parent(...))` | `Don't add Parent yourself. Attach with entity.setParent(parent)...` |
| `assets.get('x')` too early | `Asset 'x' isn't loaded yet. Load it first: await assets.load('x')...` |
| an error thrown in a system | headless: thrown as `system 'x' in world 'main': message`; in a browser: logged once, the frame carries on, and it is in `game.errors` |

## Limits and numbers

- At most 1,048,575 live entities per world (20 bits of index, 12 bits of generation in an id).
- A component field name starts with a letter or underscore (letters, digits, underscores) and can't be `constructor`, `prototype`, `toString`, `valueOf`, `hasOwnProperty`, `toJSON` or `__proto__`.
- Stages are `input`, `fixed`, `update`, `late`, `render`.
- Measured on a laptop, headless: 10,000 entities moving through `Position`, `Velocity` and `Bounds` take about 0.25 ms per fixed step; moving them through `Transform` with world matrices kept up to date, about 0.9 ms; spawning 10,000 entities takes a few milliseconds. The core's hosted runtime is under 30 KB gzipped.

<!-- Phase 2b: 3D rendering, physics, camera rigs, controllers and animation. Examples marked `// @run-2b` are executed by packages/engine/test/docs3d.test.ts. -->

# 3D, physics, camera rigs, controllers and animation (phase 2b)

Everything in this block comes from `@onceworlds/engine/modules`. Games never import three.js or Rapier: they are loaded by the modules when a game starts (three.js only for `Render3D`, Rapier only for a physics module), and their objects are reachable only through members marked unstable (`render3d.native`, `physics.raw`).

```ts
import { createGame, Transform } from '@onceworlds/engine';
import { Render3D, Physics3D, Controllers, Rigs, Animation, Mesh, Material, Light, Sky, Fog, PostFx, Camera, CameraRig, Character3D, Body3D, Collider3D, Intent } from '@onceworlds/engine/modules';
```

## A 3D game in one screen

```ts
// @run-2b
import { createGame, Transform } from '@onceworlds/engine';
import { Render3D, Physics3D, Controllers, Rigs, Mesh, Material, Light, Sky, Camera, CameraRig, Character3D, Body3D, Collider3D, Intent } from '@onceworlds/engine/modules';

const game = createGame({
  headless: true,                                   // a test: no screen. In a browser leave this out.
  modules: [Render3D(), Physics3D(), Controllers(), Rigs()],
  scene(world) {
    world.spawn([Transform({ position: [0, -0.5, 0] }), Body3D.static(), Collider3D.box({ size: [60, 1, 60] }), ...Mesh.box([60, 1, 60], Material.standard('#4fb36b'))]);
    const hero = world.spawn([
      Transform({ position: [0, 2, 0] }),
      Body3D.kinematic(), Collider3D.capsule({ radius: 0.35, height: 1.8 }),   // a body the controller moves
      Character3D(), Intent(),                                                 // walks, jumps, climbs steps; reads what Intent says
      ...Mesh.capsule(0.35, 1.8, Material.toon('#53e0ff')),
    ]);
    world.spawn([Transform(), Light.sun({ shadows: true })]);
    world.spawn([Sky.preset('dusk')]);
    world.spawn([Transform(), Camera({ projection: 'perspective', fov: 65 }), CameraRig.thirdPerson({ target: hero, distance: 6 })]);
    hero.get(Intent).move.set(0, 1);                                           // hold "forward"
  },
});
game.start();
await game.ready;                                   // Rapier's WebAssembly has loaded
game.run(2);
expect(game.world.query([Character3D]).first()!.get(Transform).position.z).toBeLessThan(-5);
```

`Render3D` makes a full-page canvas, draws every active `Camera` in order, and follows the player's Graphics setting. Headless (tests, `onceworlds check`) it has no screen: it draws nothing, keeps a record of what it would have drawn, and never throws.

## Units, axes and directions

Metres, y up, right-handed. A camera looks along its local -z; characters, vehicles and `Intent.yaw` use the same convention: a heading of 0 faces -z and a positive heading turns toward -x. Angles are radians, except the slope limits of `physics.moveCharacter` and `Light.spot({ angle })`, which are degrees because that is how they are written everywhere.

## Render3D

| Component | What it is |
|---|---|
| `Mesh` | A shape: `Mesh.box(size, material)`, `.sphere(r, m)`, `.plane(w, d, m)` (a floor facing up), `.quad(w, h, m)` (a picture facing +z), `.cylinder`, `.cone`, `.capsule`, `.torus`, `.buffer(name, m)`. They return the mesh and its `Material` as a list to spawn with. Options: `castShadow`, `receiveShadow`, `visible`, `layer`, `batch`, `outline`, `billboard: 'y'` or `'full'`. |
| `Material` | `standard` (PBR: metalness, roughness, emissive), `unlit`, `toon` (`toonSteps`), `sprite(map)`, or `Material.custom({...})`. Also `opacity`, `map`, `normalMap`, `doubleSided`, `blend: 'additive'`, `repeat`. |
| `Model` | A glTF or GLB scene: `Model.of('knight')`. Draco and Meshopt compression work. `colliders: 'mesh'` or `'hull'` gives the physics world a collider made from its triangles. |
| `Light` | `Light.sun({ shadows })`, `.hemisphere()`, `.point()`, `.spot()`. A sun's shadow covers `shadowRange` metres around where the main camera looks and snaps to texels, so shadows are crisp and don't crawl. |
| `Sky`, `Fog` | `Sky.preset('day')`, `'dusk'`, `'sunset'`, `'night'`, `'overcast'` or `'space'` is the background and the light on standard materials. `Fog.linear({ near, far })`, `Fog.exp({ density })`. |
| `Lod` | An entity whose children are one thing at several detail levels: `Lod({ distances: [10, 30] })`. |
| `PostFx` | On a camera: `tonemap` (ACES by default, exposure 1), `bloom` with a `bloomThreshold` (only what is brighter glows), `contrast`, `saturation`, `tint`, `vignette`, `fxaa`, `outline` (meshes marked `outline`), and `passes`. `PostFx.polished()` and `.cartoon()` are good starts. |
| `Camera` | The shared component from the 2D renderer: `Camera({ projection: 'perspective', fov })`, `'orthographic'` with `height`, `viewX` and friends for split screen, `target` to draw into a render target, `layers`, `order`. |

Assets are registered with `assets.add(name, { type: 'texture', url })` or `{ type: 'model', url }`; the first frame that uses one starts loading it, and a mesh or model appears when its files arrive. Images come out the right way up; models keep their clips for the `Animator`.

Many meshes with the same shape and material are drawn with one call (instancing): a thousand crates are one draw. Turn it off with `batch: false` for one whose material you change. Meshes outside the camera's view are skipped (three.js culls single objects; instanced groups that cast no shadows are culled per instance).

Things move smoothly at any frame rate: the renderer draws each entity blended between its last two fixed steps (`RenderPose`, added automatically). After moving something far in one go, call `teleport(world, entity)`.

```ts
// @run-2b
import { createGame, Transform } from '@onceworlds/engine';
import { Render3D, Mesh, Material, Camera, NullBackend3D } from '@onceworlds/engine/modules';

const backend = new NullBackend3D();                   // what headless games use: it records the last frame
const game = createGame({ headless: true, modules: [Render3D({ backend })] });
game.start();
await game.ready;
const world = game.world;
world.spawn([Transform({ position: [0, 3, 10] }), Camera({ projection: 'perspective' })]);
const crate = Material.standard('#b07a45');
for (let i = 0; i < 100; i++) world.spawn([Transform({ position: [i, 0, 0] }), ...Mesh.box(1, crate)]);
game.step(1);
expect(backend.last!.draws).toHaveLength(100);
expect(backend.stats().drawCalls).toBe(1);             // one instanced call
```

### Quality tiers

`ow.settings.quality` (low, medium or high; the platform steps it down when frames run slow) picks a tier from `QUALITY_TIERS`:

| | low | medium | high |
|---|---|---|---|
| Pixel ratio cap | 1 | 1.5 | 2 |
| Sun shadow map | 1024, hard edge | 2048, filtered | 4096, filtered |
| Lights drawn / with shadows | 6 / 1 | 12 / 2 | 24 / 4 |
| Bloom, FXAA, outline | off | on | on |
| Anisotropy | 1 | 4 | 8 |

The shadow filter is three.js's `PCFShadowMap`: recent three.js removed `PCFSoftShadowMap` (asking for it logs a warning and uses `PCFShadowMap`), so the engine never asks. `Render3D({ quality: 'low' })` ignores the player's setting (tests, benchmarks).

### Render targets: minimaps, portals, monitors

```ts
const render3d = ctx.render3d;                     // in a system; or game.world.services.render3d
render3d.target('minimap', { width: 256, height: 256 });
world.spawn([Transform({ position: [0, 80, 0] }), Camera({ projection: 'orthographic', height: 60, target: 'minimap', order: -1 })]);
world.spawn([Transform(), ...Mesh.quad(2, 2, Material.unlit({ map: 'target:minimap' }))]);   // a screen that shows it
```

Cameras draw in `order`, so the target camera goes first. A camera with `PostFx` that draws to a target or to part of the screen skips the post stack (it needs the whole screen).

### Custom materials in WGSL

```ts
// @run-2b
import { t } from '@onceworlds/engine';
import { Material, Mesh } from '@onceworlds/engine/modules';

const Hologram = Material.custom({
  uniforms: { tint: t.color('#53e0ff'), lines: t.f32(120) },
  transparent: true,
  fragment: `
    let scan = 0.5 + 0.5 * sin(in.uv.y * u.lines + time.now * 4.0);
    return vec4f(u.tint.rgb * scan, 0.6);`,
});
const parts = Mesh.sphere(1, Hologram({ tint: '#ff00aa', lines: 40 }));   // each entity has its own values
expect(parts).toHaveLength(2);
```

You write the body of a function that returns a `vec4f`; the engine writes the signature and runs it through three.js's WGSL node path (`wgslFn`), on WebGPU and on the WebGL2 fallback. The names the body can use are `in.uv`, `in.position` (world), `in.normal` (world), `in.color` (the material's colour, linear), `time.now`, `time.dt` and `u.NAME` for each uniform. Uniforms are `t.f32`, `t.bool`, `t.vec2`, `t.vec3` or `t.color` (a vec4f, linear, alpha in w). An optional `vertex` body returns a `vec3f` offset added to the vertex position (waves, wobble). `helpers` is a list of complete WGSL functions the bodies can call.

Limits: no struct declarations, no textures or samplers inside the body, no storage buffers, no loops over uniform arrays, and one function per stage. Custom materials draw one object at a time (no instancing) so every entity can have its own uniform values. A syntax error in the WGSL shows up in the browser's console when the material first draws; `Material.custom` itself checks the uniform names and that the body returns something.

### Passes and the lower layers

```ts
render3d.addPass('flash', { when: 'before', execute({ renderer, scene, camera, THREE, TSL }) { /* raw three.js, around each camera's draw */ } });
render3d.addPass('scanlines', { when: 'post', build({ TSL, input }) { return input.mul(TSL.sin(TSL.screenUV.y.mul(800)).mul(0.1).add(0.9)); } });   // a step in a camera's PostFx chain
world.spawn([Transform(), Camera({ projection: 'perspective' }), PostFx({ passes: ['scanlines'] })]);
```

`render3d.native` is `{ renderer, scene, THREE, TSL }` once there is a screen, and `null` before. It is unstable: use it for emergencies. The interface between the module and the drawing library is `Backend3D` (`render3d/types.ts`); everything it carries is plain data, which is what lets our own WebGPU renderer replace three.js later.

## Physics (Rapier)

`Physics3D()` and `Physics2D()` add a Rapier world per ECS world, stepped once per fixed step by the system named `'physics3d'` or `'physics2d'` (give a system that moves things first `before: 'physics3d'`, and one that reacts `after:`). Rapier's WebAssembly loads when the game starts (`await game.ready`); until then bodies sit still and queries find nothing. The same inputs give the same results run after run (the tests check it), in Node too.

| 3D | 2D | |
|---|---|---|
| `Body3D.dynamic()`, `.kinematic()`, `.static()` | `Body2D` the same | Mass, damping, `gravityScale`, `ccd` (fast bodies don't tunnel), `canSleep`, `lockRotation`, `velocity` (read after the step, write to change). A `kinematic` body follows its Transform and pushes dynamic ones. A body's Transform is in world space: bodies are top-level entities. |
| `Collider3D.box`, `.sphere`, `.capsule`, `.cylinder`, `.cone`, `.convex`, `.trimesh`, `.heightfield` | `Collider2D.box`, `.circle`, `.capsule`, `.convex`, `.polyline`, `.heightfield`, `.tiles` | On the body's entity, or on child entities (compound shapes). With no body above it a collider is a fixed piece of the world. `friction`, `restitution`, `density`, `sensor`, `layer`, `mask`, `offset`. |
| `Joint3D.fixed`, `.ball`, `.hinge`, `.slider`, `.spring`, `.rope` | `Joint2D.fixed`, `.hinge`, `.slider`, `.spring`, `.rope` | Between two bodies, with optional limits. |
| `GravityField.point()`, `.directional()` | the same | Planets and zones. `Physics3D({ gravity: 'fields' })` has none outside fields. |

Named geometry (convex hulls, triangle meshes, heightfields, tile grids) is registered once: `physics.shapes.trimesh('arena', vertices, indices)`, `.convex`, `.heightfield('hill', { rows, cols, heights, scale })` (3D heights are row-major, rows along +z and columns along +x, centred on the entity), and for 2D `.tiles('level', { width, height, solid: (x, y) => ... })` (runs of solid tiles become one box each). A loaded `Model` with `colliders: 'mesh'` does this for you.

Layers: `const L = defineLayers(['world', 'player', 'enemy'])`, then `Collider3D.sphere({ layer: L.bits.player, mask: L.mask('world', 'enemy') })`. Two colliders touch when each one's layer is in the other's mask.

```ts
// @run-2b
import { createGame, Transform, defineSystem } from '@onceworlds/engine';
import { Physics3D, Body3D, Collider3D, CollisionStart } from '@onceworlds/engine/modules';

const hits: number[] = [];
const game = createGame({
  headless: true,
  modules: [Physics3D()],
  systems: [defineSystem({ name: 'listen', stage: 'update', run: ({ events }) => { for (const e of events.read(CollisionStart)) hits.push(e.impulse); } })],
  scene(world) {
    world.spawn([Transform({ position: [0, -0.5, 0] }), Body3D.static(), Collider3D.box({ size: [20, 1, 20] })]);
    world.spawn([Transform({ position: [0, 3, 0] }), Body3D.dynamic(), Collider3D.sphere({ radius: 0.5, restitution: 0.4 })]);
  },
});
game.start();
await game.ready;
game.run(2);
expect(hits.length).toBeGreaterThan(0);
const physics = (game.world.services as any).physics3d;     // raycast, overlap, shapecast, gravityAt, applyImpulse, moveCharacter...
const down = physics.raycast([5, 10, 5], [0, -1, 0]);
expect(down?.distance).toBeCloseTo(10, 2);
```

Events (read with `events.read(...)`): `CollisionStart` and `CollisionEnd` carry `{ a, b, point, normal, impulse }`; `SensorEnter` and `SensorExit` carry `{ sensor, other }`. Queries on `physics` (every system's context has `ctx.physics3d`, `ctx.physics2d`, and `ctx.physics` for whichever module came first): `raycast(origin, direction, maxDistance, { mask, exclude, sensors })`, `overlap(shape, position)`, `shapecast(shape, position, direction, maxDistance)` with shapes `{ type: 'sphere', radius }`, `{ type: 'box', size }` and `{ type: 'capsule', radius, height }`; `gravityAt(position)`; `applyImpulse` and `applyForce`. Bodies spawned this step exist for queries from the next fixed step. `physics.raw` is `{ RAPIER, world }`, unstable.

`physics.moveCharacter(entity, desired, options)` is the primitive under the controllers: it slides a collider along walls, up slopes and steps, onto moving platforms and (2D) one-way platforms, and returns the movement to apply plus `grounded`, `groundEntity` and what it hit.

## Controllers

Controllers read an `Intent` component (stick `move`, `yaw`, buttons `jump`, `sprint`, `dash`, `drop`, `brake`, `boost`, `action`) and move their entity through the physics world, in the `fixed` stage before physics. Write the intent from the Input module, a bot or a test; the controllers never touch the keyboard. Buttons are held states; controllers notice presses themselves. Add `Controllers()` next to a physics module.

| Controller | Entity needs | What it gives |
|---|---|---|
| `Platformer2D({ speed, jumpHeight, ... })` | `Body2D.kinematic()` and a `Collider2D` capsule | Variable jump (let go early for a short hop), coyote time, jump buffering, one-way platforms (`Collider2D.box({ oneWay: true })`, drop through with `intent.drop`), slopes, wall slide and wall jump. A full jump reaches `jumpHeight` (the tests check it). |
| `TopDown({ speed, dash })` | `Body2D.kinematic()` and a `Collider2D` | Acceleration and friction, dash with cooldown, sliding along walls, `TopDown.grid(size)` to settle on cells. |
| `Character3D({ walkSpeed, jumpHeight, ... })` | `Body3D.kinematic()` and a capsule `Collider3D` | Walk and sprint relative to `intent.yaw`, jump with coyote and buffer, step-up (`stepHeight`), slopes, ladders (an entity with `Ladder` and a sensor collider), swimming (`Water` and a sensor box), moving platforms, and "up" taken from the local gravity: on a `GravityField` planet it stands on the surface and walks around the curve. |
| `ArcadeVehicle({ maxSpeed, grip, ... })` | `Body3D.dynamic({ mass })` and a box collider | Four ray wheels with springs: throttle on `move.y`, steering on `move.x`, handbrake drift (`brake`), boost, air control. Numbers are accelerations and rates, so a heavier chassis drives the same. `ArcadeVehicle.kart()` is looser. |
| `GridMover` and `Grid` | `Transform` | Tile-to-tile movement for tactics and puzzles: `grids.set('main', new Grid({ width, height, blocked }))`, `moveTo(grids, world, unit, x, y)`; `grid.findPath(from, to, { diagonal, maxCost })` is A*, `grid.reachable(from, range)` is a unit's movement range. Events `GridStepped` and `GridArrived`. |

```ts
// @run-2b
import { createGame, Transform } from '@onceworlds/engine';
import { Physics2D, Controllers, Body2D, Collider2D, Platformer2D, Intent } from '@onceworlds/engine/modules';

const game = createGame({ headless: true, modules: [Physics2D(), Controllers()] });
game.start();
await game.ready;
const world = game.world;
world.spawn([Transform({ position: [0, -0.5, 0] }), Body2D.static(), Collider2D.box({ size: [40, 1] })]);
const hero = world.spawn([Transform({ position: [0, 0.6, 0] }), Body2D.kinematic(), Collider2D.capsule({ radius: 0.3, height: 1 }), Platformer2D({ jumpHeight: 2.6 }), Intent()]);
game.step(20);                                          // land
const floor = hero.get(Transform).position.y;
let top = floor;
for (let i = 0; i < 60; i++) {
  hero.get(Intent).jump = i < 40;                       // hold jump
  game.step(1);
  top = Math.max(top, hero.get(Transform).position.y);
}
expect(top - floor).toBeCloseTo(2.6, 1);
```

## Camera rigs

A `CameraRig` on a camera entity moves it each frame (in the `late` stage, after the world is settled) and always reads its target at the pose the renderer draws, so a camera never jitters against its subject. Add `Rigs()` to the modules.

| Rig | Behaviour |
|---|---|
| `CameraRig.follow({ target, offset })` | A fixed offset with smoothing (2D: `offset: [0, 0, 10]` with an orthographic camera). |
| `.orbit({ target, distance, yaw, pitch })` | Around the target; the game changes `yaw` and `pitch`. |
| `.thirdPerson({ target, distance, collide })` | Behind and above, looking where the target's `Intent` looks. Pulls in at once when something is in the way and eases back out; with physics present it never ends inside a solid (the tests drop it into thousands of random poses around boxes). |
| `.firstPerson({ target, offset })` | At the eyes, turned by `Intent.yaw` and `.pitch`. |
| `.topDown({ target, distance, yaw })` | Straight down. |
| `.sideScroll({ target, deadZone, lookAhead, boundsMin, boundsMax })` | Follows along x and y outside a dead zone, looks ahead of the motion, stays inside bounds. |
| `.isometric({ target })` | A fixed heading and tilt (45 and about 35 degrees). |
| `.fitAll()` | Frames every entity with `CameraTarget` (`padding`, `minSize`, `aspect`); sizes an orthographic view or backs a perspective one away. |
| `.fixed({ points })` | Fixed angles, picked by where the target is (`points: [{ position, lookAt?, fov?, radius? }]`), cut or blended by `smoothing`. |
| `.path({ points, pathSpeed })` | A Catmull-Rom fly-by; looks at the target if there is one. |

Shake works on every rig: `shakeRig(world, camera, 0.6)` adds trauma (0 to 1); the shake grows with its square, fades by `traumaDecay`, never feeds back into the rig's smoothing, and is switched off by the player's Reduce motion setting. `gravityUp: true` takes "up" from the local gravity (planets).

```ts
// @run-2b
import { createGame, Transform } from '@onceworlds/engine';
import { Rigs, Camera, CameraRig, teleport } from '@onceworlds/engine/modules';

const game = createGame({ headless: true, modules: [Rigs()] });
game.start();
const world = game.world;
const hero = world.spawn([Transform({ position: [0, 0, 0] })]);
const cam = world.spawn([Transform(), Camera({ projection: 'orthographic', height: 14 }), CameraRig.follow({ target: hero, offset: [0, 0, 10], smoothing: 6 })]);
game.step(1);
hero.get(Transform).position.x = 20;
teleport(world, hero);
game.run(2);
expect(cam.get(Transform).position.x).toBeCloseTo(20, 1);
```

## Animation

`Animator` plays the clips of the entity's `Model`: `Animator({ clip: 'Spin' })` for one looping clip, or a state machine registered with `defineAnimator`. States are clips (or a blend of clips by a parameter); transitions fire on a function of the parameters or a yes/no parameter (`consume: true` makes it a trigger) with a crossfade in seconds; extra `layers` with a bone `mask` play on top (aim while running). The machine runs in plain data; the backend poses the model through three.js's `AnimationMixer`. A model that hasn't loaded yet doesn't advance its animation.

```ts
// @run-2b
import { createGame, Transform } from '@onceworlds/engine';
import { Animation, Animator, defineAnimator } from '@onceworlds/engine/modules';

defineAnimator('knight', {
  params: { speed: 0 },
  states: {
    idle: { clip: 'Idle', duration: 2 },
    move: { blend: { param: 'speed', clips: [{ clip: 'Walk', at: 1 }, { clip: 'Run', at: 5 }] }, duration: 1 },
    attack: { clip: 'Slash', loop: false, duration: 0.5, onEnd: 'idle' },
  },
  transitions: [
    { from: 'idle', to: 'move', when: (p) => (p.speed as number) > 0.2, blend: 0.15 },
    { from: 'move', to: 'idle', when: (p) => (p.speed as number) <= 0.2 },
    { from: '*', to: 'attack', when: 'attack', consume: true, blend: 0.05 },
  ],
});
const game = createGame({ headless: true, modules: [Animation()] });
game.start();
const knight = game.world.spawn([Transform(), Animator({ controller: 'knight' })]);
knight.get(Animator).params.speed = 3;                 // from a system, each frame
game.step(30);
expect(knight.get(Animator).state).toBe('move');
Animator.trigger(game.world, knight, 'attack');
game.step(60);
expect(knight.get(Animator).state).toBe('move');       // the slash played, and with the stick still held it went back to moving
```

## Mistakes and what the errors tell you to do (3D)

| You wrote | The error says |
|---|---|
| `Mesh.buffer('terrain')` before registering it | `Mesh.buffer('terrain') has no geometry yet. Register it first: render3d.geometry('terrain', { positions, indices }).` |
| `Material.standard({ map: 'bark' })` with no such asset | `A Material names the image 'bark', which isn't registered. Add it: assets.add('bark', { type: 'texture', url: '...' }).` |
| `Model.of('tree')` with no such asset | `A Model names 'tree', which isn't registered. Add it: assets.add('tree', { type: 'model', url: '...glb' }).` |
| a file that isn't glTF | `The model 'x' couldn't be read as glTF: ... Export it from your tool as .glb (glTF binary).` (the game keeps running) |
| `Material.custom({ fragment: 'let x = 1.0;' })` | `Material.custom needs a fragment: the body of a WGSL function that returns a vec4f...` |
| `u.glow` in a custom body with no such uniform | `Material.custom fragment uses u.glow, but the uniforms are: ... Declare it: uniforms: { glow: t.f32(1) }.` |
| `Collider3D.convex('rock')` before `physics.shapes.convex('rock', points)` | `Collider3D shape 'convex' needs shapeName: 'rock' to be registered. Call physics3d.shapes.convex('rock', ...) before spawning it.` |
| `Platformer2D` with no `Physics2D()` | `Platformer2D moves bodies through Physics2D, which this game doesn't have. Add Physics2D() to the game's modules.` |
| `Animator.play(world, e, 'zzz')` | `The animator 'knight' has no state 'zzz'. States: idle, move, attack.` |
| no WebGPU and no WebGL2 | the game keeps running without a picture; `game.errors` says `3D drawing isn't available on this device: ...` |

## Limits and numbers (3D)

- One 3D world per game: `Render3D({ worlds: ['main'] })` draws the main world (other worlds simulate but this module does not draw them yet). Models are drawn one object at a time (a clone per entity, so each can animate); instancing is for meshes.
- Measured on a laptop, headless: building the draw list for 10,000 meshes takes about 1.3 ms a frame when they stand still and about 4 ms when all of them move; 500 resting boxes step in about 0.35 ms.
- Download: three.js loads as separate chunks only when `Render3D` starts (about 330 KB gzipped for the renderer, plus 13 KB for the glTF loader the first time a model is parsed). Rapier's compat build carries its WebAssembly inside the JavaScript: about 1.3 MB gzipped for 3D and 1.6 MB for 2D, loaded only by the module that needs it.
---

# Phase 2a API: 2D, input, audio, UI, feel and posters

Everything below comes from `@onceworlds/engine/modules`. Each module is added to `createGame({ modules: [...] })` and gives systems a service on their context (`ctx.render`, `ctx.input`, `ctx.audio`, `ctx.ui`, `ctx.feel`). Outside a system, `servicesOf(game)` gives the same services, typed. Headless (no canvas, no DOM, no sound) every module still loads and every call is a quiet no-op, so the same game runs in a test. Examples marked `// @run2a` are executed by the test suite.

```ts
import { Render2D, Feel, Input, Audio, UI } from '@onceworlds/engine/modules';

const game = createGame({
  space: '2d',                       // y up, units are metres. '2d-pixels': y down, units are pixels
  modules: [Render2D({ font: 'Sora' }), Feel(), Input({ actions }), Audio(), UI({ title: 'MAZE TANK BATTLE' })],
});
```

Put `Render2D` before any module that moves a `Transform` in the fixed stage (physics, controllers): it remembers every Transform before each fixed step so the picture can blend between steps.

## Drawing in 2D

`Render2D` draws every active `Camera` (orthographic ones, in worlds that aren't 3D) in `order`, into its viewport or render target, blended between fixed steps by the world's `time.alpha`. Make a camera with `camera2D({ height, clear, order, layers, viewport, target })`.

```ts
// @run2a
import { createGame, Transform } from '@onceworlds/engine';
import { Render2D, Sprite, Shape2D, Text, camera2D, defineRenderLayers } from '@onceworlds/engine/modules';

const layers = defineRenderLayers(['floor', 'units', 'fx']);         // a higher layer draws over a lower one

const game = createGame({
  space: '2d',
  headless: true,
  modules: [Render2D({ font: 'Sora' })],
  scene(world) {
    world.spawn([Transform(), camera2D({ height: 12, clear: '#10131c' })]);
    world.spawn([Transform({ position: [0, 0, 0] }), Shape2D({ shape: 'circle', radius: 0.5, fill: '#4ee1ff', stroke: '#0b0f1a', strokeWidth: 0.1, layer: layers.units })]);
    world.spawn([Transform({ position: [0, 2, 0] }), Text({ text: 'HELLO', size: 1, outline: 0.1, layer: layers.fx })]);
  },
}).start();
game.run(1);
expect(game.errors).toEqual([]);
```

| Component | What it is | Notes |
|---|---|---|
| `Sprite` | a picture or one frame of a sheet | `image` (asset or sheet name), `frame` / `frameName`, `tint`, `flipX`, `flipY`, `anchor`, `size` (0 = from the picture), `blend`, `opacity` |
| `SpriteAnim` | plays a clip of the sprite's sheet | `clip`, `speed`, `loop`, `time`, `done`; emits `SpriteAnimDone` |
| `Shape2D` | rect, rounded rect, circle, ellipse, polygon, line | fill, stroke, shadow, dash, `points` for polygons and lines |
| `Text` | text in the world | `size` is world units; the letters draw at screen resolution; `font`, `weight`, `outline`, `maxWidth`, `align`, `shadow` |
| `NineSlice` | a stretchable panel from one picture | `left top right bottom` corners in picture pixels |
| `Tilemap` | tile layers of a registered map | `map`, `only` (one tile layer), `cell` |
| `Particles`, `Trail` | see Feel | drawn by the same renderer |
| `RenderTarget` | a named surface | `Camera({ target: 'minimap' })` draws into it; `Sprite({ image: 'target:minimap' })` shows it |

Every drawable has `layer` (0 to 31: what it draws with, and what cameras see it through `Camera.layers`), `z`, `ySort` (lower on screen draws in front, for top-down worlds), `opacity` and `visible`. Draw order is layer, then z, then y, then the order they were made in. Things outside the camera's view are skipped.

- Pictures are assets: `assets.add('hero', { type: 'image', url: 'hero.png' })`. A sprite whose picture isn't loaded yet starts loading it and appears when it arrives. A generated picture (a canvas) goes in with `assets.provide('name', canvas)`.
- Sheets: `render2d.sheet('hero', { image: 'hero', frameWidth: 16, frameHeight: 16, clips: { run: [1, 2, 3], hit: { frames: [4, 5], loop: false } } })`; atlases take `frames: { name: [x, y, w, h] }` (`atlasFrames(json)` reads TexturePacker and Aseprite exports). `range(2, 5)` is `[2, 3, 4, 5]`.
- Tile maps: `loadTiled(json)` (Tiled's JSON export with embedded tilesets) and `loadLDtk(json, { level })` give a `TilemapData` (layers of cell ids with flip flags, tilesets, and the objects placed in the editor as plain records); `createTilemap(...)` makes an empty one. Register it with `render2d.tilemap('level1', data)` and spawn `Tilemap({ map: 'level1' })`; two Tilemaps with different `only` and `z` put a layer in front of the characters. Tileset pictures are the assets named like the image file (or the `images` map you pass).
- Fonts: `render2d.loadFont('Sora', [600, 800])` (or `Render2D({ fonts: [...] })`) loads a Google Font; text draws in the fallback until it is ready.
- `Render2D({ pixelPerfect: true })` snaps the zoom to whole pixels per unit and positions to whole pixels, with nearest-neighbour filtering.
- `Render2D({ background })` is the colour of the screen in a frame where no camera draws (a game made only of UI); it is cleared to it instead of showing the last frame.
- `render.teleport(entity)` draws an entity where it is now for the next frame, with no blending (it jumped). Add `NoInterpolation()` to an entity that should never be blended (a camera a rig moves every frame).
- `render.quality` is the player's Graphics setting (`'low' | 'medium' | 'high'`), `render.reducedMotion` their Reduce motion setting. `render2d.worldToScreen(x, y, out)` and `screenToWorld(x, y, out)` convert through the cameras that drew last.
- Lower layers than the renderer: `RendererBackend` (in `src/render`) is the one interface between the engine and a drawing technology; the draw list (`DrawCmd`) is plain numbers. `render.addPass({ name, when: 'before' | 'after', draw(ctx, view) {} })` adds a pass in every view; `render.backend.native` is the canvas, and unstable.

## Input and touch

An action map says what the player can do, once, for every device. In a system, `input.axis('move')` is `{ x, y }` (y points down, like the platform's stick: up is -1), `input.pressed('jump')` is true for the one fixed step in which it went down (even when a frame runs two steps or none), `input.held('dash')`, `input.released('dash')`.

```ts
// @run2a
import { createGame, defineComponent, defineSystem, t } from '@onceworlds/engine';
import { Input, controlsFromActions } from '@onceworlds/engine/modules';

const actions = {
  move: Input.axis2d({ keys: 'wasd arrows', stick: true, pad: 'left' }),
  jump: Input.button({ keys: 'Space', pad: 'A', touch: { label: 'Jump', big: true } }),
  aim: Input.pointer({ lock: 'while-playing', drag: 'right-half' }),
};
const Body = defineComponent('Body', { x: t.f32(), jumps: t.u16() });

const game = createGame({
  headless: true,
  modules: [Input({ actions })],
  systems: [defineSystem({ name: 'walk', stage: 'fixed', query: [Body], run({ query, input, time }) {
    query.each((_e, b) => {
      b.x += input.axis('move').x * 5 * time.dt;
      if (input.pressed('jump')) b.jumps++;
    });
  } })],
  scene(world) { world.spawn([Body()]); },
}).start();

const { input } = game.services as { input: import('@onceworlds/engine/modules').InputService };
input.startRecording();
input.setAxis('move', 1, 0);                 // a test or a bot holds the stick; a player uses keys, a pad or a thumb
input.tap('jump');
game.run(1);
const run = input.stopRecording();            // one entry per fixed step: JSON-safe, enough to replay the run
expect(game.world.query([Body]).first()!.get(Body).jumps).toBe(1);
expect(run.frames).toHaveLength(60);
expect(controlsFromActions(actions)).toEqual([
  { keys: ['W', 'A', 'S', 'D'], action: 'Move' },
  { keys: ['Arrows'], action: 'Move' },
  { keys: ['Space'], action: 'Jump' },
  { keys: ['Mouse'], action: 'Aim' },
]);
```

- Keys are written as names (`'Space'`, `'E'`, `'Shift'`, `'wasd'`, `'arrows'`, `'ijkl'`) and matched by the physical key. Mouse buttons: `Input.button({ mouse: 'left' })`. Gamepad: `pad: 'A'` (standard layout names: A B X Y LB RB LT RT BACK START L3 R3 UP DOWN LEFT RIGHT) and `pad: 'left' | 'right' | 'dpad'` on an axis, with a dead zone. Devices are guarded: a phone whose `getGamepads` throws just stops being asked.
- On a touch screen the engine sets the platform's `ow.controls` from the map: a stick (`stick: true` presses WASD or the arrows, so it needs one of those in `keys`; `stick: 'analog'` gives the exact direction) and up to four labelled buttons, the `big` one first. The map can change mid-game (`input.setActions(map)`): the controls follow in the next frame, and a button that keeps its name stays held. They show only while play is on: automatic when the Flow module runs the game (not on the title, scoreboard and results), or `input.setPlaying(false)` for your own screens. `input.device` says what the player used last (`'keyboard' | 'mouse' | 'touch' | 'pad'`), for key hints versus touch hints.
- `controlsFromActions(actions)` writes the `controls` list for onceworlds.json (`{ keys, action }`, inside the platform's limits); give an action `label`, `hint: { keys, action }` or `hint: false` to word it yourself.
- Pointer: `input.pointer('aim')` has `x y dx dy down pressed released locked type`. `drag: 'right-half'` makes a finger drag on that half the look (so the thumb stick keeps the other). Presses on the UI's buttons and panels never reach it (`ui.blocks`).
- Pointer lock follows the platform guide. `lock: 'while-playing'` locks the mouse only from a click in the world while play is on. `input.lock.openPanel('inventory')` frees it (the UI does this for modal views) and `closePanel` takes it back when the last panel closes. A lock lost any way but the game's own release is the player asking for the menu: `input.lock.onLost(() => openMenu())`. A refused lock never pauses: it leaves a `Click` hint (`input.lock.hint` is `'esc'`, `'click'` or `null`) and the next click asks again; the browser's refusal just after Esc is waited out; `SecurityError` and `NotSupportedError` make `input.lock.unsupported` true (drag to look). Touch never locks.
- Recording and playback are per fixed step: `input.startRecording()`, `input.stopRecording()` and `input.play(recording, onDone)`. During playback the steps take their input from the recording, so a replay or a headless test runs the same game. `input.press`, `release`, `tap`, `setAxis`, `setPointer`, `keyDown` and `keyUp` let a test or a bot press things.

## Audio

```ts
// @run2a
import { createGame } from '@onceworlds/engine';
import { Audio } from '@onceworlds/engine/modules';

const game = createGame({ headless: true, modules: [Audio()] }).start();
const { audio } = game.services as { audio: import('@onceworlds/engine/modules').AudioService };
audio.play('coin');                              // built in: blip coin jump land step hit impact whoosh explosion powerup pickup pop
                                                 // click tick select error lose countdown go fanfare
audio.play('hit', { pitch: 1.1, volume: 0.8 });  // pitch varies a little by itself (4%) for built-in effects
audio.ui('click');                               // the ui bus
audio.music.play('action', { intensity: 0.6 });  // procedural music: menu, action, chill, or your own song
expect(audio.history.map((h) => h.name)).toEqual(['coin', 'hit', 'click']);   // headless: nothing sounds, but what was asked is recorded
```

- Sound starts on the player's first tap or key in the game (the iPhone rule) and nothing plays before that: effects asked for earlier are dropped, music waits. `audio.onUnlock(fn)` runs when sound is first possible. It follows the platform's volume and mute with no code (the platform wraps everything connected to the destination); don't build volume controls.
- `audio.play(name, options)` takes a file you added (`assets.add('boom', { type: 'audio', url: 'boom.mp3' })`; load ahead of time with `await audio.load('boom')`, or the first play only starts the load) or a built-in synthesized effect. A file with the same name wins. Options: `bus`, `volume`, `pitch`, `pitchVariation`, `at`, `follow`, `pan`, `range`, `loop`, `delay`, `duck`. It returns a handle (`stop(fade)`, `setVolume`, `setPitch`) and never throws; an unknown name warns once and is silent.
- Buses: `music`, `sfx`, `ui`, each with `audio.bus('music').volume` and `.muted`. `audio.duck('music', { to, attack, hold, release })` dips a bus for a big moment; `explosion` and `fanfare` do it by themselves.
- Positional: `audio.play('hit', { at: enemy })` pans and fades by where the thing is relative to the active camera (the lowest `order`): stereo in a 2D view, a `PannerNode` with the listener on the camera in 3D (`range` is how far it carries). Sounds too far away are skipped. `follow: true` keeps updating while an entity moves.
- Music: a song is `{ bpm, tracks: [{ voice, pattern }] }` with patterns like `'C3 . E3 -'` (note, rest, hold) and drum voices `kick snare hat` with `'x . . .'`; a track's `minIntensity` makes it a layer that comes in as `audio.music.setIntensity(0.9)` rises. `audio.music.stop(fadeSeconds)`, `setVolume`. `Sequencer` (note events for a time span) is exported for tests.
- Voices are limited (40 at once, 6 of one sound). The raw context is `audio.context`, and the nodes sounds connect to are `audio.busNode('sfx')`.

## UI

A view is a function that returns what to show; it runs every frame while it is shown, and the result is laid out with flex rules around the platform's safe zones and drawn over the game.

```ts
// @run2a
import { createGame } from '@onceworlds/engine';
import { Render2D, UI, servicesOf } from '@onceworlds/engine/modules';

const game = createGame({ headless: true, modules: [Render2D(), UI({ title: 'TANK BATTLE', theme: { colors: { accent: '#ff3b30' } } })] }).start();
const { ui } = servicesOf(game);

ui.view('menu', () =>
  UI.panel({ pad: 16, gap: 10 },
    UI.label('Paused', { size: 32, color: 'accent' }),
    UI.button('Resume', { kind: 'primary', hotkey: 'Escape', onClick: () => ui.removeView('menu') }),
    UI.button('Restart', { onClick: () => {} }),
  ), { anchor: 'center', modal: true });

// `preview` lays a tree out without drawing it (tests and tools): sizes in screen pixels.
const { hits, rect } = ui.preview(UI.panel({ pad: 8 }, UI.button('Play', { key: 'play' })), { width: 390, height: 844, touch: true, anchor: 'bottom' });
expect(hits[0].rect.h * 0.8).toBeGreaterThanOrEqual(56);            // buttons are at least 56 px tall on a phone (44 elsewhere)
expect(rect.y + rect.h).toBeLessThanOrEqual(844);
```

- Nodes: `UI.panel`, `UI.row`, `UI.column`, `UI.label`, `UI.button`, `UI.bar`, `UI.image`, `UI.list`, `UI.grid`, `UI.spacer`, `UI.custom`, `UI.backdrop`. Children can be nodes, strings, arrays and `false`/`null`. Layout props: `dir`, `gap`, `pad`, `align` (`start center end stretch`), `justify` (`start center end between around`), `w` and `h` (a number, or `'fill'`), `grow`, `minW`... `absolute: { anchor, x, y }`. Colors are theme names (`'accent'`, `'panel'`, `'dim'`) or any color. A button has `kind` (`primary secondary danger ghost`), `hotkey` (shown on it while a keyboard is in use), `disabled`, `onClick` (called inside the pointer event, so a mouse lock or a sound may start from it) and plays a click.
- Views: `ui.view(name, build, { anchor, offset, order, safe, fill, modal, when })`, `ui.removeView(name)`. The build function gets `{ ui, world, time, theme, width, height, scale, flow, clock, dt, me, touch, portrait, safe, state }`. A `modal` view takes every click, frees the mouse (`input.lock.openPanel`), and the arrow keys, Tab, Enter and Space move through and press its buttons.
- Units: the screen is `width` x `height` UI units, scaled from a phone to a desktop (about 0.8 to 1.7 pixels a unit). Text is never smaller than 16 screen pixels (`tiny: true` on a key cap), and buttons never smaller than 44 (56 on touch).
- Theme: `UI({ theme: { font, colors, radius, border, shadow: 'hard' | 'soft' | 'none' } })` or `ui.setTheme(...)`. The default is flat and bold: solid colors, a thick dark edge, a hard shadow, one modern typeface (Sora, loaded from Google Fonts). No glass, no glow.
- Safe zones are built in: the platform's buttons at the top left (130 x 56), the lobby's Ready strip (90 px at the bottom, in the `lobby` phase), the thumbs' corners while the touch controls are up, and the notch and home bar (`env(safe-area-inset-*)`). A view that would overlap one slides the shortest way out of it (`safe: false` to opt out, for a full-screen menu). `safeLayout`, `avoidZones` and `ui.safe.zones` are exported for custom UI.
- World-space UI that follows entities (screen-space, constant size, drawn over the world): `NameTag({ text, playerId, you, badge })` with the platform avatar head, `HealthBar({ value, max })` (hidden while full), `Marker({ icon, label, offscreen })` with an arrow at the screen's edge when its target is out of view, clear of the safe zones. Add the component to the entity; there is no system to write.
- Immediate mode: `ui.draw.rect`, `box`, `circle`, `line`, `polygon`, `text`, `image`, `nineSlice`, `bar`, `particles`, `custom`, `push(x, y, scale, rotation)` / `pop()`, `withAlpha`, `measure`. Call them each frame from a system in `update`, `late` or `render`; they draw over the game in call order. `image('avatar:<playerId>')` is a player's avatar head (rounded with `{ round: true }`), `image('target:name')` a render target.
- `ui.toast('Saved')` floats a note over the page and never moves anything. `ui.callout('GOAL!')` pops big text in the middle. `ui.confetti()` fills the screen (gentler with Reduce motion). `ui.sound(name)` plays a UI sound. `ui.overlay` is the HTML layer: `el(tag, { text, style, on })` builds elements with `textContent` only (there is no innerHTML), `mount(id, element, { anchor })` puts one over the game with the platform's corners kept clear. Names from other players are only ever drawn as text.
- The standard screens read the `FlowState` resource the Flow module writes, and are views you can replace by name or switch off (`UI({ flowViews: { title: false } })`): `flow.title` (the game's name, one Play button; emits `PlayPressed`), `flow.countdown` (3-2-1-GO, from `until` and the match clock, with beeps), `flow.banner` (round and goal), `flow.scoreboard` (standings with avatars, "YOU", bot tags, the time left), `flow.results` (podium with confetti, awards, Play again; emits `PlayAgainPressed`), `flow.hud` (round and timer while playing), `flow.spectating`. `until` is on the match clock (`time.match`); set `ui.clock = () => world.time.now` when there is no room. Without a Flow resource they draw nothing.

## Game feel

```ts
// @run2a
import { createGame, defineComponent, t, Transform } from '@onceworlds/engine';
import { Feel, Particles, feelOf, tween } from '@onceworlds/engine/modules';

const Gauge = defineComponent('Gauge', { value: t.f32() });
const game = createGame({ headless: true, space: '2d', modules: [Feel()] }).start();
const feel = feelOf(game.world);                         // or `ctx.feel` in a system

const coin = game.world.spawn([Transform(), Gauge()]);
tween(coin, Transform, { scale: 1.3 }, { ms: 180, ease: 'backOut' });       // any numeric field; a number for a vector sets every part
tween(coin, Gauge, { value: 10 }, { ms: 500, ease: 'cubicInOut' });
feel.squash(coin);                                        // wider and shorter, springing back to exactly its own size
feel.shake(0.4);                                          // trauma: the shake is its square, smooth, and fades by itself
feel.hitStop(80);                                         // freeze the world for 80 real ms
feel.particles.burst('sparks', coin);                     // dust sparks smoke confetti explosion, or feel.particles.define(...)
feel.floatText('+3', coin, { color: '#ffd23f' });
game.world.spawn([Transform(), Particles({ preset: 'smoke', rate: 20 })]);
game.run(1);
expect(coin.get(Gauge).value).toBe(10);
expect(coin.get(Transform).scale.toArray()).toEqual([1, 1, 1]);   // squash replaced the grow tween, then returned to rest
```

- Tweens: `tween(entity, Component, { field: target }, { ms, ease, delay, repeat, yoyo, unscaled, onDone })`; targets are numbers, lists, parts (`{ y: 4 }`), colors (`'#ff8000'`) and, for `rotation`, an angle in radians about z. Eases: `linear quadIn quadOut quadInOut cubicIn/Out/InOut quartOut sineIn/Out/InOut expoIn/Out circIn/Out backIn/Out/InOut elasticIn/Out/InOut bounceIn/Out smooth`, or a function. A new tween on a field that one is already moving replaces it. `feel.tweens.value(from, to, options, apply)`, `.delay(ms, fn)`, `.sequence([() => tween, 200, { call }])`. They run on the world's frame time (slow motion and hit-stop slow them; `unscaled: true` keeps real time) and end quietly when their entity is gone.
- `feel.shake(trauma, { camera, rotate })` moves the camera for the frame only (never a Transform); `feel.hitStop(ms)` and `feel.slowMo(scale, ms)` set the world's `time.scale` and give it back; `feel.squash`, `stretch` and `pop` deform a Transform's scale and settle exactly; `feel.floatText(text, at, options)`; `feel.flash(color, ms)`.
- Particles are pooled and cheap, stepped on the world's time, drawn as one command per pool, with a seeded stream so a poster is always the same. `feel.particles.burst(preset | spec, at, { count, scale, color, layer, direction, spread })`; a spec is `{ count, life, speed, size, color, gravity, drag, spin, shape: 'circle' | 'square' | 'spark', blend }`. `Particles({ preset, rate })` emits from an entity (`burst` for a one-off, `despawn: true` to clean up); `Trail({ width, color, maxAge })` draws a ribbon behind one. Counts follow the Graphics setting (about 0.4, 0.7, 1) and are cut with Reduce motion.
- Reduce motion (`ow.settings.reducedMotion`) is respected by everything here: no shake, no flash, gentler particles and squash, no float-text drift.

## Posters

`game.poster(name, setup)` registers a staged scene for the store. Opening the game with `?poster=cover` builds that scene instead of the game, at an exact size and a pixel ratio of 1, frozen: the scene is set up, settled for a fixed number of simulated steps with the seed fixed, drawn once, and `document.body.dataset.ready` becomes `'1'` when fonts and images are in (`'error'` with `data-error` when not). `?poster=list` puts the names in `document.body.dataset.posters`.

```ts
// @run2a
import { createGame, Transform } from '@onceworlds/engine';
import { Render2D, Shape2D, Text, feelOf, Feel } from '@onceworlds/engine/modules';

const body = { dataset: {} as Record<string, string | undefined> };      // a page's document.body, in a test
const game = createGame({
  headless: true, space: '2d',
  modules: [Render2D({ poster: { search: '?poster=cover&w=640&h=360', body } }), Feel()],
  scene(world) { world.spawn([Transform(), Text({ text: 'THE GAME' })]); },   // not built in poster mode
});
game.poster('cover', (p) => {
  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 0.8 s before the picture is taken
});
game.start();
await (game.services as { poster: { done: Promise<void> } }).poster.done;
expect(body.dataset.ready).toBe('1');
```

- The default size is 1920 x 1080 (the store's 16:9 thumbnails); `icon` is 512 x 512 and `badge` 256 x 256; `{ width, height }` in the options or `&w=` `&h=` in the address change it (64 to 4096). The setup gets `{ name, width, height, game, world, settle(seconds), rng(stream), camera(options) }` and may be async. The game's own scene is cleared first, the renderer is told to draw without blending between steps and at the highest quality and full motion, and a UI toast or callout works as in the game.
- `Render2D` includes poster support (switch off with `Render2D({ poster: false })`); the `Poster()` module is for other renderers. `servicesOf(game).poster.active` is the poster's name while one is being made: the Flow module should not join a room then.

## What phase 2a does not do

3D (cameras with a perspective projection, meshes, the three.js backend), physics, camera rigs and controllers are phase 2b. Rooms, replication, the Flow module itself (it writes `FlowState`, the standard screens read it) and bots are phase 2c; until it lands, set `FlowState` by hand as the sample in `packages/engine/examples/2d-sample` does. The sample (Coin Dash) runs on the built package in a browser and headless in the test suite.

# Phase 2c: multiplayer, the game shell, bots and saves

`Net`, `Flow`, `Bots` and `Save` are modules (`import { Net, Flow, Save, ... } from '@onceworlds/engine/modules'`). They follow every rule of the platform guide's "Multiplayer rooms" and "Matches" by construction, so a game written with them does not repeat those lessons: who plays (participants) and who watches (spectators), ready-up (a friends server's host starts, a public server starts by itself once everybody readied, and joining never starts a game), records keyed by `match.id`, deadlines in match time, a seat that is held for a player who reloads or drops, idle players, a host that changes (also during a paused match: the new host takes over on `matchstart`, `host`, `reconnect`, `matchresume` and from its own ticker), messages that reach only the pages connected now (anything a late page must see is room state), validation of everything received, and the room's message budget.

## Net: replicated worlds

A component replicates when its schema says so; an entity replicates when it has an owner (a player's id, or `'host'`).

```ts
// @run2c
import { createGame, defineComponent, t, Transform } from '@onceworlds/engine';
import { Net, netOf } from '@onceworlds/engine/modules';
import { fakeRoom } from '@onceworlds/engine/test';

const Health = defineComponent('Health', { hp: t.f32(100) }, { net: { replicate: true, owner: 'host' } });   // the host decides it, everyone sees it
const hub = fakeRoom({ latency: 30 });
const open = (name: string) => createGame({ headless: true, platform: hub.page(name).platform, modules: [Net({ components: [Health] })] }).start();
const ann = open('ann');
const bo = open('bo');
const joined = Promise.all([ann.join({ private: true }), bo.join()]);
hub.advance(200);
await joined;
const play = (seconds: number) => { for (let i = 0; i < seconds * 60; i++) { hub.advance(1000 / 60); ann.step(); bo.step(); } };

// bo's page simulates bo's character; ann's page (the host) holds its health.
const hero = bo.world.spawn([Transform(), Health()], { owner: 'bo' });
play(0.5);
const copy = ann.world.query([Health]).first()!;
hero.get(Transform).position.set(4, 2, 0);                 // only what changed travels, quantized by the schema's `step`
copy.get(Health).hp = 40;
play(0.6);
expect(copy.get(Transform).position.x).toBeCloseTo(4);     // ann sees bo's position (drawn ~100 ms behind, between updates)
expect(hero.get(Health).hp).toBe(40);                      // bo sees the host's health
expect(netOf(ann)!.owner(copy)).toBe('bo');
```

- **Ownership.** `world.spawn([...], { owner: me.id })`: that player's page simulates the entity and writes its components. `{ owner: 'host' }`: the host's page (spawn it in a system with `authority: 'host'`). A component with `net: { owner: 'host' }` is written by the host even on a player's entity (health, score). Nobody else's writes are accepted: a page that sends what it doesn't own is refused (`net.rejects` says why).
- **Component options** (`defineComponent(name, schema, { net })`): `replicate: true | ['field', ...]`, `owner: 'host'`, `rate` (updates a second, default 20), `interpolate: false` (apply updates as they come), `snap` (a jump bigger than this is a teleport, shown at once), `predict: true` (the owner's page predicts the field; an update from the host corrects it smoothly, at once past `snap`), `priority` (more important when the budget is short). Numbers with a `step` are sent as whole counts of it. `net.teleport(entity)` marks the next update as a teleport.
- **Drawing others.** Remote entities are drawn about 100 ms behind the clock their updates are stamped with (the room's clock, which they can't forge), between two updates; `Net({ interpDelay, extrapolate })` change it. A new host continues from the newest state it received, and the host's entities are also kept in the room's state (a host that reloads alone gets them back); tag an entity `Ephemeral()` to leave it out.
- **Budget.** Updates share `room.budget` (60% by default, `Net({ budgetShare })`); when more changed than fits, the entities nearest to this page's own entity (or `Net({ focus })`) go first and the rest wait their turn. Spawns and despawns are always sent. `net.stats` counts bytes, messages and what was refused.
- **Prefabs.** `net.prefab('Tank', (args) => [Transform(), Sprite(...)])` and `net.spawn('Tank', { color: 3 }, { owner })`: other pages build the same components (including the ones that don't replicate) before applying the state. Or react to arrivals with `net.onSpawn((entity, info) => ...)` and `net.onDespawn`.
- **Rounds.** `net.setEpoch(key)` (Flow does it for you) forgets every networked entity and drops late messages of the round before.
- **Raw access.** `net.room`, `net.send(data, { to })` (not with a `_n` key), `net.onMessage(fn)` for messages that are not Net's.
- `ctx.net`, `game.services.net` and `netOf(game)` are the same service; `net.acting()` is true on the page running the host's rules (the room's host, taken over, and not waiting for a paused match). With `Net` in the game, `game.isHost()` and `authority: 'host'` mean exactly that.

### Calls and claims

```ts
// @run2c
import { createGame, t } from '@onceworlds/engine';
import { Net, netOf } from '@onceworlds/engine/modules';
import { fakeRoom } from '@onceworlds/engine/test';

const hub = fakeRoom({ latency: [5, 60] });
const names = ['ann', 'bo', 'cy'];
const games = names.map((n) => createGame({ headless: true, platform: hub.page(n).platform, modules: [Net()] }).start());
const joined = Promise.all(games.map((g, i) => g.join(i === 0 ? { private: true } : {})));
hub.advance(300);
await joined;
const fired: string[] = [];
const applied: string[] = [];

const fire = games.map((g) => netOf(g)!.rpc('fire', {
  args: { angle: t.f32(0, { min: -3.2, max: 3.2 }) },     // checked on arrival: a bad call is refused, never run
  to: 'host',
  rate: 10,                                               // a player may call it this often a second
  validate: (from, args) => args.angle !== 0 || 'aim somewhere',
  run: (from, args) => void fired.push(`${from.id}:${args.angle.toFixed(1)}`),
}));
// Contested things ("two players reach one chair") are claims: the host settles them by the room's clock on each message.
const sit = games.map((g) => netOf(g)!.claim('sit', { args: { chair: t.u8() }, key: (a) => String(a.chair), window: 150, apply: (winner) => void applied.push(winner.from.id) }));

fire[1]({ angle: 1.5 });
fire[2]({ angle: 0 });                                     // refused by validate
const results = [sit[1]({ chair: 3 }), sit[2]({ chair: 3 })];
for (let i = 0; i < 60; i++) { hub.advance(1000 / 60); games.forEach((g) => g.step()); }
expect(fired).toEqual(['bo:1.5']);
expect(applied).toHaveLength(1);                           // applied once, for the earliest
expect((await Promise.all(results)).sort()).toEqual(['lost', 'won']);
```

- `net.rpc(name, { args, to: 'host' | 'others' | 'all', validate, run, rate, hostOnly })` returns the function to call. Entity arguments (`t.entity()`) are turned into the same entity on every page; an entity that is not networked throws, saying how to fix it.
- `net.claim(name, { args, key, window, validate, apply, rate })` returns a function giving a promise of `'won' | 'lost' | 'rejected' | 'timeout'`. A claim sent to a host that left is sent again to the new one; `apply` should check the game's own state, so a claim that was applied twice is harmless.

## Flow: the game shell

```ts
// @run2c
import { createGame, defineComponent, defineSystem, t, Transform } from '@onceworlds/engine';
import { Net, Flow, Awards } from '@onceworlds/engine/modules';
import { simulate } from '@onceworlds/engine/test';

const Racer = defineComponent('Racer', { id: t.playerId(), x: t.f32() }, { net: { replicate: true } });
const Run = defineSystem({
  name: 'run', stage: 'fixed', query: [Racer],
  run({ query, flow, net, time }) {
    if (flow.phase !== 'playing') return;
    query.each((entity, racer) => { if (net.isMine(entity) || net.owner(entity) === 'host') racer.x += (racer.id === 'bot:1' ? 5 : 6) * time.dt; });
  },
});

const config = {
  modules: [Net({ components: [Racer] }), Flow.rounds({
    title: { logo: 'DASH' },                                          // one Play button over the live game
    lobby: { hint: 'First to the line' },                             // players move about while the others ready up
    settings: [{ id: 'rounds', label: 'Rounds', options: [1, 2, 3], default: 2 }],   // the host picks; frozen when the match starts
    roster: { fill: 3, bot: (_ctx: unknown, seat: { id: string }) => [Transform(), Racer({ id: seat.id })] },   // bots fill empty seats
    timing: { banner: 0.5, roundEnd: 0.5, scoreboard: 0.5, results: 3 },
    round: {
      banner: 'FIRST TO 20',
      spawn: (_ctx: unknown, seat: { id: string }) => [Transform(), Racer({ id: seat.id })],       // each seat's body, on its own page
      isOver: (ctx: any) => { let over = false; ctx.world.query([Racer]).each((_e: unknown, r: any) => { if (r.x >= 20) over = true; }); return over; },
      rank: (ctx: any) => { const x: Record<string, number> = {}; ctx.world.query([Racer]).each((_e: unknown, r: any) => { x[r.id] = r.x; }); return x; },
    },
    scoring: Flow.placePoints([3, 2, 1]),
    results: { awards: [Awards.most('Most laps', 'laps')] },
  })],
  systems: [Run],
};

const result = await simulate(config, { humans: 2, bots: 1, seed: 3, seconds: 40, latency: [10, 40], chaos: ['reload-host'], until: (r) => r.matches > 0 });
expect(result.errors).toEqual([]);
expect(result.ranking).toHaveLength(3);                                // two players and a bot, ranked, through a reload of the host
```

- **Shapes.** `Flow.rounds` (lobby, countdown, rounds with a goal banner, scoreboard between rounds, podium and results with awards), `Flow.turns` (a turn order, a time limit, players who are away or idle skipped; bots play through `turn.bot`), `Flow.dropIn` (a title, then play at once: worlds and hangouts), `Flow.coop` (stages with `isOver` returning `'win'` or `'lose'`, checkpoints that let late friends in and flush the saves, `onFail: 'retry' | 'end'`), `Flow.none` (only fills `FlowState`).
- **What it does for you.** Joins as the game loads (`autoJoin: false` to do it yourself; never in poster mode). A title with one Play button and `room.hideLobby()` until it is pressed (the UI's `PlayPressed` event does it, and wakes the sound). The live lobby (`lobby: { scene, spawn, hint, ui }`, default `'bar'`: the platform adds the Ready/Start strip). Settings the host picks (`flow.setSetting`, frozen from `ctx.settings`). A countdown in step with `match.startsAt`. Rounds with banners and scoring (`Flow.placePoints`, `Flow.firstTo`, `Flow.mostPoints`), results with awards, kept visible over the lobby until each player closes them (`flow.dismissResults()`; the UI's `PlayAgainPressed` does it). `flow.endMatch()`. Late arrivals watch (`FlowState.spectating`) and are let in at the next round (`admit: 'round'`) or checkpoint. `progress` rules save stats, award badges and submit leaderboard scores once per match, and a `ranked` join reports the placings.
- **Phases survive reloads and host changes.** The phase is derived from `room.match.phase` plus one record the host keeps in room state (`ow.flow`, keyed by the match id; a record from an earlier match reads as "no game yet"). A host that takes over follows the record for a moment (what the last host wrote may still be on its way) and then continues from it; junk written over it is put right.
- **Rules run in the right place.** In a round, `setup(ctx)` runs on every page (build the level; what it spawns goes when the round ends), `spawn(ctx, seat)` on the page that plays the seat (the host for bots), `populate(ctx)` once on the host (not again after a host change), `isOver`, `rank` and `end` on the host. `ctx.seats` are everyone in the match (seats never shrink), `ctx.present` those connected and not idle, `ctx.round`/`ctx.rid` a round id that never repeats, `ctx.resumed` a page that loaded into a round under way, `ctx.rng(name)` the match's seeded streams.
- **Stats.** `flow.stat('bankShots', 1)` from any page goes to the host; `Awards.most(title, 'bankShots')` reads them. `flow.skip()` (host) ends a banner, result, scoreboard or results screen; `flow.endRound()`, `flow.endTurn()`, `flow.checkpoint()`.
- **FlowState** gained `shape`, `title`, `hint`, `countdown` (and `until` is that during the countdown), `host`, `friends`, `canStart`, `ready`, `waitingFor`, `players`, `settings`, `paused`, `turn`, `checkpoint`, `outcome`, `me`, `participant` and `error` (why a join was refused). The standard screens read them; `ui/flow-lobby.ts` adds the lobby (players, ready marks, the host's settings as buttons) and a note for watchers.

## Saves

```ts
// @run2c
import { createGame, t } from '@onceworlds/engine';
import { Save, defineSave } from '@onceworlds/engine/modules';
import { fakeRoom } from '@onceworlds/engine/test';

// Version 2 renamed `won` to `wins`: the step from 1 to 2 brings old saves along.
const Profile = defineSave('Profile', { wins: t.u32(), nick: t.string(12, 'new') }, { version: 2, migrate: { 1: (old) => ({ wins: old.won ?? 0 }) } });
const hub = fakeRoom();
const platform = hub.page('ann').platform;
await platform.save.set('save.Profile', { v: 1, d: { won: 6 } });
const game = createGame({ headless: true, platform, modules: [Save(Profile)] }).start();
await game.ready;
expect(game.world.resource(Profile).wins).toBe(6);       // loaded and migrated
game.world.resource(Profile).wins += 1;                  // changes are written after a pause, never every frame
hub.advance(2000);
game.step(2);
expect(await platform.save.get('save.Profile')).toEqual({ v: 2, d: { wins: 7, nick: 'new' } });
```

`defineSave(name, schema, { version, migrate, key, debounce, auto })` makes a resource that is also a save; `Save(...saves)` loads them as the game starts (`game.ready` waits; `save.ready` for a scene that needs them), writes changes after `debounce` ms (default 1500), and again when the page is hidden (`visibilitychange`) and on dispose. `save.touch(Profile)` for `auto: false`, `save.flush()`, `save.problems` (invalid fields dropped, a migration that threw). Data from a newer version of the game is left alone, never overwritten.

## Bots

`Bot` is a component (id, name, skill, reaction, aimNoise, mistake); `spawnBot(world, seat, components, skill)` makes a bot owned by the host, so a new host carries them on. `botProfile(skill, rng)` turns a skill from 0 to 1 into a reaction time, aim error and mistake chance, with a little personality. Helpers: `reactionDelay`, `aimAt`, `leadTarget`, `slips`, steering (`seek`, `flee`, `arrive`, `avoid`, `wander`, `limit`) and `utility(actions)` (the best-scoring action, a little inertia, now and then the second best). `defineBotSystem({ name, query, think, act })` runs on the host only; each bot `think`s when its own reaction clock fires and `act`s every step. Flow's `roster: { fill, bot, skill }` seats them.

## Testing multiplayer

`simulate` plays Flow games for you (it presses Play, Ready and, for a friends host, Start; `autoplay: false` to drive it yourself, `{ rematch: true }` for more than one match) and returns `ranking` (best first, humans and bots), `standings`, `awards` and `matches`. `bots: n` seats `humans + n` in a game whose roster has a bot to give. New chaos scenarios: `'reload-player'`, `'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 the shared records) and `'host-leaves'`; `env.later(ms, fn)`, `env.addPlayer()` and `env.leave(i)` are there for your own. `page.idle()` flags a player idle in the fake room. `packages/engine/test/fuzz.test.ts` drops random scenarios into random matches and checks that every one ends ranked with every page showing the same world (`RUNS=1000 FUZZ=5` for a long hunt).

## UI over a 3D game (kits)

`Render3D` draws on its own canvas, so the UI, name tags, immediate-mode drawing and posters need a transparent 2D canvas above it: `modules: [Render3D(), Render2D({ overlay: true }), UI(), ...]`. With `overlay` the 2D canvas is cleared every frame, sits on top and still takes the pointer. `NameTag`, `HealthBar` and `Marker` on entities of a 3D world are projected through the 3D camera (a place behind the camera is sent far off to its side, so a marker's edge arrow still points at it); `render3d.worldToScreen(x, y, z, out)` gives the same screen place in CSS pixels for your own drawing. The engine's particles and floating text are 2D renderer features: in 3D make bursts from small emissive meshes and draw numbers with `ui.draw.text` at a `worldToScreen` place (the kits do both). A teleported `ArcadeVehicle` (write its `Transform`) waits one step for the physics body to arrive before its wheels push. `simulate` plays no frame of a game before its `game.ready` (physics loaded).


<!-- Phase 3b: delivery. Written by hand: nothing in this section is executed by the tests. -->

## Hosting and versions (phase 3b)

On Onceworlds a game does not bundle the engine: it says `"engine": "1"` in `onceworlds.json` and the platform serves it.

- **The field.** `"engine"` is a major (`"1"`, the newest 1.x the platform has, resolved on every request, so fixes arrive without a redeploy), a minor (`"1.2"`) or an exact version (`"1.2.3"`, the same bytes for good). A deploy that names a version the platform doesn't have fails with the versions that exist. Without the field a page gets no import map: a game may bundle its own copy of the engine instead.
- **The import map.** For an engine game the platform puts, before the SDK and the game's own scripts, `<script type="importmap">` mapping `@onceworlds/engine` to `https://onceworlds.com/engine/<version>/engine.js` and `@onceworlds/engine/modules` to `.../modules.js`, plus a `modulepreload` for both. Chunks (three.js, Rapier, the glTF loader) load by relative paths from those files when a module asks for them, so they need no entry. `@onceworlds/engine/test` is not hosted: it is for Node (`npm i -D @onceworlds/engine`). The game's CSP names `https://onceworlds.com/engine/` in `script-src` and `connect-src` and nothing else of the platform's origin. Safari needs 16.4 for import maps.
- **Where the files are.** `<platform origin>/engine/<version>/` for the code and `<platform origin>/engine/wasm/rapier<2|3>d-<hash>.wasm` for Rapier's WebAssembly, public, with CORS `*` (the game's frame has no origin of its own) and `Cross-Origin-Resource-Policy: cross-origin`. One address for every game, never a game's own host.
- **Versions.** A released version is frozen (`npm run release -w @onceworlds/engine` writes it to `platform/engine`), cached for a year (`immutable`) and never changes; the platform's build stops if the engine's source no longer builds a frozen version's bytes. Old versions stay for good. The version the source is at but has not been released is served too, for a few minutes' cache (revalidated in local development).
- **Rapier's size.** The browser builds of Rapier carry the WebAssembly as base64 text in their JavaScript (a third bigger than the binary, and parsed as a 4 MB string). The hosted runtime takes it out: the JavaScript is about 200 KB, and the binary (3D 3.0 MB, 2D 2.3 MB; with gzip 1.1 MB and 0.9 MB, with Brotli 0.8 MB and 0.65 MB) is fetched by URL, streamed and compiled as it downloads, named by its hash so it can be cached for good and shared by every engine version that keeps the same Rapier. Previously 3D physics was 1.6 MB and 2D 1.3 MB gzipped, in script. Node is unchanged: `@onceworlds/engine/test` and the `dist` bundles keep it inline.
- **Cache caveats.** Browsers key their HTTP cache by the top-level site, which for a play page is always onceworlds.com, so the engine's address is the same key for every game. A game's frame is sandboxed, though, with an opaque origin, and some browsers may treat that as unshareable: a returning player may download the engine again per game or per visit, from the CDN's edge cache. The first visit is the cost to watch (core 30 KB gzipped, the modules about 120 KB, three.js about 190 KB, loaded only when a game uses 3D).
