# @leumas/game-kit

The Leumas game simulation kernel: ECS, fixed-step loop, physics seam, prefabs, defineGame, netcode. Isomorphic — runs identically in the browser and in a Node sim worker. Contains no three.js, no...


The Leumas game simulation kernel: ECS, fixed-step loop, physics seam, prefabs, `defineGame`, netcode.

**It is isomorphic, and that is the point.** Everything here runs unchanged in a browser tab and in a
Node sim worker, so the authoritative server and the predicting client execute the *same* rules over
the *same* components. There is no `three`, no DOM, no React and no clock anywhere beneath any entry
point. `pnpm check:game` fails the build if that stops being true.

Presentation lives in `@leumas/game-3d`. Games live in `@leumas/games`. Neither belongs here.

## The five things worth knowing

**1. The public surface is five factories, deliberately.** Every JSDoc block in this package is read
by `check:props-coverage`, the function index and the MCP schema generator. A kernel of two hundred
loose functions would be two hundred tool entries no agent could choose between, so the kernel exports
`createWorld`, `createLoop`, `defineComponent`, `defineGame` and `createPrefabPack`, and everything
else is a documented key on a returned handle.

**2. Entity ids are packed, and the generation bits are load-bearing.** An id is
`(generation << 20) | index`. When an entity is destroyed its slot is recycled with `generation + 1`,
so a stale id fails `world.alive(e)` instead of silently addressing whoever moved in. Eleven bits of
generation keeps an id inside a positive `Int32`, which is what lets it live in an `eid` component
field and leaves `-1` free for `NO_ENTITY`.

**3. `headless` is the entire client/server delta.** It skips the `interpolate` and `present` phases
and nothing else. A server is not a different world; it is the same world with two phases off. The
matching rule on the content side is that `resolvePrefab` strips `Render` when headless — which is
what lets one prefab file feed both sides without a build step or a naming convention to drift.

**4. The loop truncates; it does not catch up.** Past `maxCatchUp` steps the accumulated time is
thrown away and `onStarve` fires. A four-second GC pause must cost four seconds of *simulated* time.
Draining the debt means 240 late steps, each making the next frame later still — that is the spiral of
death, and admitting the time is gone is the only reliable escape. On a server, `onStarve` belongs on
a dashboard: a room that starves is a room whose players are being cheated, and it is otherwise
invisible.

**5. `world.rng` is the only randomness.** A `Math.random()` inside a system desyncs client from
server — both run the same code over the same inputs, get different numbers, and diverge silently over
minutes rather than failing at once. Use `rng.fork(label)` for a new subsystem so adding one does not
shift every other system's stream and break a replay that used to pass.

## Entry points

| Subpath | What | Status |
|---|---|---|
| `.` | ECS, loop, prefabs, `defineGame`, rng, math | shipped |
| `./character` | the pure-JS controller prediction replays, plus static box collision | shipped |
| `./physics` | the Rapier seam — dynamic-imports the WASM, returns opaque handles | shipped |
| `./net` | protocol, snapshot/delta, interest, interpolation, prediction | shipped |
| `./ai` | steering, flow fields, sensing | P7 |
| `./world` | chunk maths, streaming policy, the shrinking zone | P9b |

## The split that matters most: `/character` versus `/physics`

Rapier is deterministic for a given binary but does not promise **bit-identical** results across
architectures. Reconciliation works by rewinding to the server's state and re-running every input the
client has not had acknowledged — so if that replay does not reproduce the server's arithmetic
exactly, the correction never converges and the player rubber-bands forever.

So the line is drawn once, here:

| | Moved by | Authority | Predicted? |
|---|---|---|---|
| The local player | `/character`, pure JS | server, replayed by the client | **yes** |
| AI, remote players | `/physics` or `/character` | server | no — interpolated |
| Ragdolls, vehicles, debris, thrown props | `/physics` (Rapier) | server | no — interpolated |

If you reach for `/physics` inside a function on the `/character` path, prediction quietly stops
converging. Take a query result as an argument instead.

`/character` collides against **axis-aligned boxes**, not meshes. Arenas, cover, crates, walls,
floors and platforms are boxes; terrain is not. When a title needs to walk on a mesh, the answer is a
heightfield or a navmesh, not a swept triangle solver bolted on here.

## Five traps

**Never hold `Component.array.x` across a `create()`.** Storage grows by doubling and reallocates the
backing TypedArrays. Read `.array` each tick.

**Never store the result of `query()`.** It is a cached view, stable only until the next structural
change. Iterate it and let it go.

**`physics.drain()` is destructive and per-step.** Exactly one consumer may call it, once, after each
`step()`. A second caller gets an empty array and its triggers silently never fire.

**Snapshot writing is O(players x visible entities).** Interest management makes that cheap while
players are spread out and does nothing once they are not: 24 dispersed players cost 1.4 ms/tick,
but 60 players standing in one 60-metre circle cost 43 ms against a 33 ms budget. The fix is sharing
one computed frame per interest cell, not a faster encoder. See `bench/net.bench.mjs`.

**`physics.takeSnapshot()` is ~1.5KB per body** (measured: 312KB for 201). It is for late-join
keyframes and editor undo — never a per-tick operation.

## Example

```js
import { createWorld, createLoop, defineComponent } from '@leumas/game-kit';

const Transform = defineComponent('Transform', { x: 'f32', y: 'f32', z: 'f32' });
const Velocity = defineComponent('Velocity', { x: 'f32', y: 'f32', z: 'f32' });

const world = createWorld({ components: [Transform, Velocity], headless: true, seed: 1337 });

world.addSystem({
  id: 'integrate',
  phase: 'physics',
  reads: [Velocity],
  writes: [Transform],
  run: ({ world, dt }) => {
    const q = world.query([Transform, Velocity]);
    const t = Transform.array;
    const v = Velocity.array;
    for (let n = 0; n < q.length; n++) {
      const i = world.indexOf(q[n]);          // id -> index, once
      t.x[i] += v.x[i] * dt;
      t.y[i] += v.y[i] * dt;
      t.z[i] += v.z[i] * dt;
    }
  },
});

world.create({ Transform: { x: 0, y: 2, z: 0 }, Velocity: { x: 1, y: 0, z: 0 } });

const loop = createLoop({ hz: 30, step: (dt) => world.step(dt), onStarve: (ms) => console.warn('starved', ms) });
loop.start();
```

## Verify

```sh
pnpm --filter @leumas/game-kit test    # 191 tests across 4 entry points
pnpm check:game                        # the renderer-free guarantee, plus its own self-test
node shared/packages/game-kit/bench/physics.bench.mjs   # tick p50/p95/p99 against the frame budget
node shared/packages/game-kit/bench/net.bench.mjs       # bytes/sec and write cost per client
```

The tests worth knowing about: **the convergence test** in `net.test.js` runs a two-client, 3,000-tick
match over 150ms RTT with 5% loss and 5% reordering and asserts both clients converge — and asserts the
impairments really happened, because a convergence test over a clean link proves nothing.
**`REPLAY CONVERGES`** in `character.test.js` asserts that re-running
inputs from a saved state reproduces the future exactly — that is the property prediction depends on,
and it fails silently rather than loudly when broken. **`isomorphic.test.js`** asserts in plain Node,
with no DOM shim, that no module beneath any entry point reaches for three, react or a browser global.


---
Source: shared/packages/game-kit/README.md
Canonical: https://docs.leumas.tech/p/packages/game-kit
