# EasyMultiplayer Performance Mode — Developer API Guide

This is the developer-facing guide for authoring a game in **performance mode** (the
`experimental/performance-mode/` slice). Performance mode is **data-first**: your canonical simulation
state is plain records produced by a schema-generated state backend, your `step` runs against a unified
context, and the render side consumes a read-only mirror fed over a bus. Nothing in canonical state is a
class instance, and nothing that crosses the worker boundary is anything other than structured-cloneable
plain data.

The eight sections below cover the whole authoring surface, followed by two worked examples
(Ball/Pong and a Pac-Man-shaped multi-entity excerpt).

> Nicer API slice: `PerformanceModeEngineContext` now exposes `ctx.world()` as the first implemented
> nicer performance-mode authoring facade. It provides class-bound `world.get` / `world.all` /
> `world.spawn` / `world.destroy`, routed `world.input`, participants, discovery, tick metadata, and
> advanced `world.table(name)`. This is still performance mode: canonical state is schema-backed plain
> data, input reads still route through `ctx.query`, and the facade does not expose the raw engine,
> transport, renderer, backend internals, or an Easy Mode/proxy-style object graph.

---

## 1. Schema and state definition

Canonical simulation state is declared with the schema helpers from
`experimental/performance-mode/state-backend.js`:

- `schemaScalar(type)` — a single typed field (`'number'`, `'string'`, `'boolean'`).
- `schemaRecord({ ...fields })` — a flat record of scalars (one row's shape).
- `schemaTable(record, { id, order })` — a keyed table of records. `id` is `'auto'` (auto-allocated
  numeric ids) or `'string'` (caller-supplied ids); `order` is `'insertion'` or `'id'`.

```js
import { schemaScalar, schemaRecord, schemaTable } from '../state-backend.js';

export const SCHEMA = {
  // Root scalar (a HUD-style counter), read cheaply, never mirrored as a table op.
  rallies: schemaScalar('number'),
  ball: schemaTable(
    schemaRecord({ x: schemaScalar('number'), y: schemaScalar('number'),
                   vx: schemaScalar('number'), vy: schemaScalar('number') }),
    { id: 'auto', order: 'insertion' },
  ),
  paddle: schemaTable(
    schemaRecord({ y: schemaScalar('number'), score: schemaScalar('number') }),
    { id: 'string', order: 'id' },
  ),
};
```

**Data-first rule:** every row in canonical state is a **plain record** — just scalar fields. There are
**no class instances in canonical state**. The backend stores, snapshots, hashes, and rolls back these
plain records; class instances could not be deterministically serialized or value-compared.

---

## 2. The deterministic step

In `schema-generated` engine mode the engine constructs a `PerformanceModeEngineContext` (from
`experimental/performance-mode/engine-context.js`) and hands it to your `step`. The context is the *only*
surface your game code touches — it carries `state`, `tick`, `dt`, plus the routed engine services.

```js
// The unified context is the step's argument. (The raw engine step signature is
// (state, inputs, tick, engineCtx); in schema mode games author against the single ctx instead.)
export function step(ctx) {
  const dt = ctx.dt;       // tick delta time
  const tick = ctx.tick;   // current tick number
  // ctx.entity / ctx.entities / ctx.spawn / ctx.destroy  -> canonical entity authoring
  // ctx.query / ctx.participants / ctx.discoverParticipants -> routed engine services
}
```

The step **must be deterministic**: identical inputs from the same start state must produce identical
canonical state (and identical hashes). Read nothing outside `ctx`; derive nothing from wall-clock time
or ambient randomness (seed any RNG from `ctx.tick`).

Entity authoring through the context:

- `ctx.entity(Class, ctx.state.table, id)` — a typed wrapper bound to one row (or `null`).
- `ctx.entities(Class, ctx.state.table)` — iterate typed wrappers over a table.
- `ctx.spawn(Class, ctx.state.table, initialRecord, { id })` — create a row.
- `ctx.destroy(entityOrTable, idOrOptions)` — remove a row.

The first nicer authoring slice is `ctx.world()`. It is a thin facade over the same context, not a new
backend and not proxy/easy mode. It resolves tables from `Class.table`, then delegates to the same
schema-generated backend context:

- `world.get(Class, id)` — `ctx.entity(Class, ctx.state[Class.table], id)`.
- `world.all(Class)` — `ctx.entities(Class, ctx.state[Class.table])`.
- `world.spawn(Class, id, initialRecord, options?)` — `ctx.spawn(Class, ctx.state[Class.table], initialRecord, { ...options, id })`.
- `world.destroy(entityOrClass, id?, options?)` — destroy by wrapper or by class-bound table/id.
- `world.input(playerId)` — convenience over `ctx.query(playerId, { id: playerId }, ...)` that returns
  the game input payload.
- `world.query`, `world.participants`, `world.discoverParticipants`, `world.tick`, `world.dt`,
  `world.state`, and `world.table(name)` are pass-through authoring conveniences.

Before:

```js
let paddle = ctx.entity(Paddle, ctx.state.paddle, id);
if (!paddle) {
  paddle = ctx.spawn(Paddle, ctx.state.paddle, { y: 25, score: 0 }, { id });
}
const input = ctx.query(id, { id }, (inputs) => inputs[0] ?? null);
paddle.moveBy(input?.move ?? 0, ctx.dt, FIELD);
```

After:

```js
const world = ctx.world();
let paddle = world.get(Paddle, id);
if (!paddle) {
  paddle = world.spawn(Paddle, id, { y: 25, score: 0 });
}
const input = world.input(id);
paddle.moveBy(input?.move ?? 0, world.dt, FIELD);
```

Both forms are supported. Use `ctx.world()` when class-bound table lookup makes the step clearer; use
the lower-level `ctx.entity` / `ctx.entities` / `ctx.spawn` / `ctx.destroy` API when explicit table
handles are more appropriate.

---

## 3. Reading input: `ctx.query`

Player input is read **only** through `ctx.query(participantId, queryCtx, predicate)`. This is the sole
input path inside a step — there is **no raw engine access** and no direct input map. Routing through
`query` keeps the read in the engine's query log, which is what drives correct rollback/re-simulation
after a late or corrected input.
The real engine strips control metadata from intent envelopes before the predicate runs: if
`getLocalInputs()` returns `{ input: { dx: 2 }, discoverable: true }`, the predicate sees `{ dx: 2 }`
as the game input value, not the full envelope.

```js
for (const id of ctx.participants()) {
  const move = ctx.query(id, { id }, (inputs) => inputs[0] ?? null);
  // ...apply `move` to this participant's entity...
}
```

The `predicate` receives the participant's gated input and must return a concrete value (a field or a
derived value), never the raw accessor. `ctx.participants()` is the current membership; new joiners are
admitted via `ctx.discoverParticipants(predicate, limit)`.

---

## 4. Consuming the render mirror

The render side's data source is a `RenderMirror` (from `experimental/performance-mode/render-mirror.js`).
The render side **does not own simulation state** — it owns a projection. It is fed entirely by bus
messages:

- `mirror.snapshot` (carried by `em.bus.anchor`) — a full-state anchor (bootstrap / resync / rollback
  rebase) that establishes generation + baseTick.
- `mirror.patch` (carried by `em.bus.mirror`) — an incremental per-tick forward patch, applied in
  sequence order.

```js
mirror.apply(message); // message.kind is 'mirror.snapshot' | 'mirror.patch' | 'mirror.rebaseFromTick'
const paddle = mirror.table('paddle').get('P1'); // read-only projected row
```

Reads are read-only projected rows (`mirror.table(name).get(id)` / `.ids()`). When a patch can't apply
(generation mismatch / sequence gap), the mirror signals `requiresRebase` and the host requests a resync
anchor — it never fabricates state. The mirror is a pure consumer; the simulation lives on the sim side.

---

## 5. Bus and performance-mode wiring

The host fans canonical input out to one or more sim buses and tracks which is render-authoritative.

For ordinary host integrations, prefer `PerformanceModeSession` over hand-constructing
`SimulationEngine`, `BusRuntimeAdapter`, `SchemaMirrorBridge`, and `RenderMirror` separately:

```js
import { createPerformanceModeSession } from '../experimental/performance-mode/performance-mode-session.js';

const session = createPerformanceModeSession({
  transport,
  schema: SCHEMA,
  initialState: createInitialState,
  step,                         // step(ctx), or raw (state, inputs, tick, ctx)
  getLocalInputs: sampleInput,   // returns { discoverable, input }
});

session.connect();
session.pump({ advanceMs: 100 });
render(session.mirror);
```

`PerformanceModeSession` owns the product-safe synced stack (`syncedTick`, attendance, bootstrap,
recovery), the schema-generated backend, bus adapter, mirror bridge, and render mirror. Hosts should
provide transport, schema, initial state, deterministic step code, and local input sampling; render code
reads `session.mirror`.

Schema-generated performance mode uses backend-owned snapshot scheduling:

- `snapshotInterval` is the logical checkpoint/rollback anchor cadence in ticks.
- `fullSnapshotEvery` is the hard full-snapshot refresh cadence, counted in logical anchors.
- `derivedSnapshotInterval` is only a schema-generated backward-compatible alias when
  `snapshotInterval` is absent; new code should use `snapshotInterval`.

For example, `snapshotInterval: 5` and `fullSnapshotEvery: 8` creates logical anchors at ticks 5,
10, 15, ... and hard full-state anchors at ticks 40, 80, ...

- `BusManager` (`experimental/performance-mode/bus-manager.js`) — owns a monotonic `inputBatchSeq`,
  dispatches a canonical input array to every active bus, records per-bus status, and reports when a
  batch has been applied everywhere (`allActiveApplied`).
- `WorkerRenderHost` (`experimental/performance-mode/worker-host.js`) — the host side of one bus across a
  real `worker_threads` boundary: owns the `RenderMirror`, posts input/control envelopes to the worker,
  and applies inbound `em.bus.anchor` / `em.bus.mirror` frames.

Wiring sketch:

```js
const host = new WorkerRenderHost(new Worker(workerUrl, { workerData }));
await host.waitForBootstrap();           // first frame is the bootstrap anchor

const manager = new BusManager();
manager.addBus('A', busA);               // busA exposes sendInputBatch(seq, inputs)
manager.dispatchBatch([{ participantId: 'P1', tick: t, intent: { input: { left: true } } }]);
await host.advance(1);                    // host-paced tick; the mirror updates from the bus
const paddle = host.mirror.table('paddle').get('P1');
```

> **Note (V0.2 / not yet implemented at the engine level):** the `em.bus.inputBatch` envelope (and
> `BusManager.dispatchBatch` that emits it) is part of the V0.2 bus protocol but is **not yet wired to
> the real EasyMultiplayer engine** — it is exercised today against the in-process `WorkerSim` and the
> schema backend only. Treat batched dispatch as a forward-looking host API, not a live engine feature.

---

## 6. Plain data vs class instances

The boundary is sharp:

- **Simulation state is plain data.** Every canonical row is a record returned by the schema backend —
  scalar fields only, no methods, no prototypes. This is what gets snapshotted, hashed, rolled back, and
  serialized across the bus.
- **Render objects can be full class instances.** Once a row reaches the render side as plain data, the
  renderer is free to wrap it in sprites, view models, or any class it likes — render objects never
  cross back to the sim.
- **Typed wrapper classes are a convenience over records, not state.** A wrapper like `Ball` or `Paddle`
  (in `experimental/performance-mode/games/ball.js`) declares `static table` and a `bind(record, meta)`
  that stashes the row as `this.s`; its methods mutate `this.s.*`. The **record** is the state and is
  what crosses the boundary — the wrapper class does not.

```js
export class Paddle {
  static table = 'paddle';
  bind(record, meta) { this.s = record; this.id = meta?.id ?? null; }
  moveBy(delta, dt, field) { this.s.y = Math.max(0, Math.min(field.height, this.s.y + delta * dt)); }
}
```

---

## 7. Worker boundary constraints

Everything posted across the `worker_threads` boundary must be **structured-cloneable plain data**. The
following can **NOT** cross and will either throw a `DataCloneError` or be rejected by the bus guards
(`assertNoLeakedInternals` / `validateOutbound`):

- **class instances** (only the underlying plain record may cross, never the wrapper),
- **functions** (no callbacks/closures on the wire),
- **proxies** (e.g. live tracked-state proxies),
- **`__em*` keys** (internal backend bookkeeping must never leak),
- **live backend state** (the backend object itself, decoders, mirrors-by-reference).

Only `em.bus.*` envelopes carrying plain snapshot/patch data cross. The same envelopes work in-process or
across a thread — the bus layer is identical; only the transport changes.

---

## 8. Migrating V0.1 → V0.2

V0.1 game code made **direct engine/state calls**. V0.2 routes **everything** through
`PerformanceModeEngineContext` and the schema backend. Migrating a step means replacing direct reads with
the context surface:

| V0.1 (direct) | V0.2 (`ctx`) |
| --- | --- |
| read entities off the engine/state object | `ctx.entity(Class, ctx.state.table, id)` / `ctx.entities(Class, ctx.state.table)` |
| read a player's input directly | `ctx.query(participantId, queryCtx, predicate)` |
| read the roster off the engine | `ctx.participants()` (admit via `ctx.discoverParticipants`) |
| hold class instances as state | declare a schema (`schemaTable`/`schemaRecord`) and store plain records |

The rule of thumb: if your V0.1 code touched the engine or raw state directly, in V0.2 it goes through
`ctx`. The backend then snapshots/hashes/rolls back the resulting plain records for you.

---

## Example A — Ball / Pong

A single ball plus one paddle per participant. Note `schemaScalar` + `schemaTable` for state,
`ctx.query` for input, and `ctx.entities` / `ctx.entity` for authoring.

```js
import { schemaScalar, schemaRecord, schemaTable } from '../state-backend.js';

export const SCHEMA = {
  rallies: schemaScalar('number'),
  ball: schemaTable(schemaRecord({ x: schemaScalar('number'), y: schemaScalar('number'),
                                   vx: schemaScalar('number'), vy: schemaScalar('number') }),
                    { id: 'auto', order: 'insertion' }),
  paddle: schemaTable(schemaRecord({ y: schemaScalar('number'), score: schemaScalar('number') }),
                      { id: 'string', order: 'id' }),
};

export class Ball {
  static table = 'ball';
  bind(record, meta) { this.s = record; this.id = meta?.id ?? null; }
  advance(dt) { this.s.x += this.s.vx * dt; this.s.y += this.s.vy * dt; }
}

export function step(ctx) {
  const dt = ctx.dt ?? 1;

  // Advance every ball (typed wrappers over plain rows).
  for (const ball of ctx.entities(Ball, ctx.state.ball)) ball.advance(dt);
  if (ctx.entities(Ball, ctx.state.ball).ids().length === 0) {
    ctx.spawn(Ball, ctx.state.ball, { x: 10, y: 5, vx: 4, vy: 3 });
    ctx.state.rallies += 1; // root scalar
  }

  // Each participant drives a paddle; input is read ONLY through ctx.query.
  for (const id of ctx.participants()) {
    const input = ctx.query(id, { id }, (inputs) => inputs[0] ?? null);
    let paddle = ctx.entity(Paddle, ctx.state.paddle, id);
    if (!paddle) paddle = ctx.spawn(Paddle, ctx.state.paddle, { y: 25, score: 0 }, { id });
    if (input && typeof input.move === 'number') paddle.moveBy(input.move, dt);
  }
}
```

## Example B — Pac-Man-shaped (multi-entity: players + ghosts)

The same concepts scale to several tables. Canonical rows stay plain records (positions/indices as
scalars); per-tick RNG is seeded from `ctx.tick` so ghost AI stays deterministic.

```js
export const SCHEMA = {
  game: schemaTable(schemaRecord({ stateName: schemaScalar('string'),
                                   dotsEaten: schemaScalar('number'),
                                   graphId: schemaScalar('string') }), { id: 'string', order: 'id' }),
  pacmen: schemaTable(schemaRecord({ nodeIndex: schemaScalar('number'),
                                     positionX: schemaScalar('number'),
                                     positionY: schemaScalar('number'),
                                     score: schemaScalar('number') }), { id: 'string', order: 'id' }),
  ghosts: schemaTable(schemaRecord({ ghostIndex: schemaScalar('number'),
                                     nodeIndex: schemaScalar('number'),
                                     scared: schemaScalar('number') }), { id: 'string', order: 'id' }),
};

export function step(ctx) {
  const rng = makeRng((ctx.tick ?? 0) + 1); // deterministic per-tick RNG

  // Spawn a pacman row for each participant that has none.
  for (const pid of ctx.participants()) {
    if (!ctx.entity(Pacman, ctx.state.pacmen, 'pacman:' + pid)) {
      ctx.spawn(Pacman, ctx.state.pacmen, { nodeIndex: SPAWN, positionX: 0, positionY: 0, score: 0 },
                { id: 'pacman:' + pid });
    }
    // Player direction comes ONLY from ctx.query.
    const dir = ctx.query(pid, { id: pid }, (inputs) => {
      const raw = inputs[0] || {};
      return { up: !!raw.up, down: !!raw.down, left: !!raw.left, right: !!raw.right };
    });
    ctx.entity(Pacman, ctx.state.pacmen, 'pacman:' + pid).step(dir);
  }

  // Ghosts are simulated (no input) — chase the nearest pacman.
  for (const ghost of ctx.entities(Ghost, ctx.state.ghosts)) ghost.chase(ctx, rng);
}
```

In both examples, the wrapper classes (`Ball`, `Paddle`, `Pacman`, `Ghost`) only *bind* to records and
mutate `this.s`; the **records** are the canonical state that snapshots, hashes, rolls back, and crosses
the bus to the `RenderMirror`.
