# @leumas/game-3d

Presentation for the Leumas game engine: the ECS-to-Object3D bridge, pointer-lock controllers and camera rigs, asset loading, and debug draw. Browser only â€” the simulation kernel is...


Presentation for the Leumas game engine: the ECS-to-`Object3D` bridge, pointer-lock input and camera
rigs, asset loading with reference counting, debug draw, and the runtime that wires them together.

The simulation lives in [`@leumas/game-kit`](../game-kit) and **never imports this package**. The
dependency runs one way — presentation reads the world and never writes to it. If the renderer could
nudge a position, the picture would be an input to the game and a headless server would play a
different match than the one on screen.

## The five things worth knowing

**1. Three-ful at the root, and that is legal *here*.** The rule the bundle guards enforce is about
**entry points**, not packages: this one is never on a boot path, and every reach into it is a dynamic
`import()` from a route-split surface. That is the same allowance `check-features-eager-three`
documents for the `solar` nav-view. Import it eagerly from a page that renders before login and you
will make that guard right and this README wrong.

**2. Interpolation is the bridge's reason to exist.** The simulation runs at a fixed rate the display
does not share — 30 Hz sim on a 144 Hz monitor is normal. Every `Transform` carries the previous
tick's pose alongside the current one, and `sync(alpha)` blends between them. Skip it and a perfectly
good 30 Hz simulation looks like a 30 Hz slideshow no matter how fast the client renders. Rotation is
**slerped**, not lerped: component-wise interpolation of a quaternion shortens the vector and the
result speeds up and slows down through the turn.

**3. Reference counting is what keeps a long session alive.** Geometry and materials hold WebGL
buffers that garbage collection does not touch. Dispose too eagerly and the first crate destroyed
blanks the other nine hundred, with no error. Never dispose and GPU memory climbs until the canvas
goes black, minutes into play and nowhere near the cause. So the registry hands out **clones that
share geometry**, counts them, and frees the original only when the last one is released.

**4. Actions, not keys.** Every binding is `move.forward -> [KeyW, ArrowUp]`. Codes are
`KeyboardEvent.code`, which is *physical position*, so WASD stays under the same fingers on AZERTY and
Dvorak. Remapping is not optional — left-handed players and anyone who cannot reach a chorded key need
it — and this is the difference between it being a feature and being a refactor.

**5. Every browser dependency is injected.** `element`, `doc`, `now`, `createRenderer`. Not for
purity: it is what lets the whole assembly, including its teardown, be tested in Node with no GPU. A
runtime that can only be verified by looking at it is a runtime whose `dispose()` is never checked.

## Entry points

| Subpath | What |
|---|---|
| `.` | the barrel — everything below |
| `./runtime` | `createGameRuntime` — scene, loop, input, bridge, wired in the right order |
| `./bridge` | ECS → `Object3D`, interpolated |
| `./assets` | source strings → models, cached and reference counted |
| `./controller` | action bindings, pointer lock, the FPS camera |
| `./debug` | collider overlays and the performance snapshot |
| `./demo` | `createBoxArena` — the walkable test level, as pure data |
| `./react` | `<GameCanvas>` — a sized box and a teardown, nothing more |

## Four traps

**A `Modal` must never live inside the canvas host.** `.lms-chud` sets `container-type`, which makes
it the containing block for `position: fixed`, and Studio's modal backdrop is fixed and not portalled.

**Never mount two runtimes on one element.** Each takes a WebGL context; browsers cap live contexts at
roughly sixteen, and after a dozen navigations every canvas in the tab renders black. `dispose()`
calls `forceContextLoss()` for exactly this reason — `renderer.dispose()` alone is not enough.

**Resize from a `ResizeObserver` on the element, never a window listener.** A panel can be dragged
wider without the window changing at all, and the canvas would stay stretched until reload.

**Do not drive the HUD from React state.** A game that calls `setState` sixty times a second spends
its frame budget on reconciliation. `GameCanvas` takes a render prop and the runtime exposes
`stats()`; read it on an interval, not per frame.

## Example

```js
import { createGameRuntime, createBoxArena } from '@leumas/game-3d';
import { createPrefabPack } from '@leumas/game-kit';

const arena = createBoxArena();
const pack = createPrefabPack(arena.prefabs, 'arena-v1');

const runtime = createGameRuntime({
  element,
  arena,
  pack,
  hz: 60,
  onFrame: ({ character }) => hud.setHeight(character.y),
});

runtime.start();
element.addEventListener('click', () => runtime.controller.requestLock());

// Later, without fail:
runtime.dispose();
```

## Verify

```sh
pnpm --filter @leumas/game-3d test   # 91 tests
pnpm check:cinematic                 # the bundle-weight and JSX guards
```

The test worth knowing about is `arena.test.js`: it walks a character across the demo level headless —
up the staircase without jumping, onto the 1-unit platform only *with* a jump, into cover, and around
the perimeter for three thousand steps without escaping or producing a `NaN`. It cannot prove a
picture appeared, but it proves the kernel, the collision world and the controller agree about a
level, which is the part that would otherwise only surface by walking around in a browser.


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