@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...
@leumas/game-3d
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 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
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
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.
What it exports
68 exports: 20 components, 0 hooks, 48 helpers. Import from @leumas/game-3d.
BANDSCLIENT_SCHEMACROSSFADE_MSDEFAULT_BINDINGSDEFAULT_BINDINGSDWELL_MSENVIRONMENTSENVIRONMENTSGAITSGameCanvasHUD_PANELSLAND_MSMAX_LAMPSORBITORBITSPECTATESPECTATESTEAL_OLDESTSTEAL_QUIETESTSTINGER_DEBOUNCE_MSapplyEnvironmentapplyFpsCameraapplyFpsCameraapplySideCameraapplyThirdPersonCameraapplyThirdPersonCameracollectStatscollectStatscreateAnimatorcreateAssetRegistrycreateAssetRegistrycreateBoxArenacreateBoxArenacreateBoxWorldDebugViewcreateBoxWorldDebugViewcreateChunkMeshercreateFpsControllercreateFpsControllercreateGameRuntimecreateGameRuntimecreateInputStatecreateInputStatecreateMusiccreateNetGameRuntimecreatePhysicsDebugViewcreatePhysicsDebugViewcreatePointerLookcreatePointerLookcreatePrimitiveResolvercreatePrimitiveResolvercreateRenderBridgecreateRenderBridgecreateSilentAudiocreateSpatialAudiocreateSpectatorControllercreateSpectatorControllercreateTerrainStreamercreateThreeAudiocrossfadeGainsdefaultRenderergaitPosegroupByChunklampSpotspickGaitreadHudreleaseObjectreleaseObjectresolveArena