Engine

Realtime & 3DPreview
@wabbit/tome-enginev0.2.3

Domain-neutral real-time runtime substrate — a Three.js scene/actor loop, an input pipeline (devices to bindings to profiles to transforms), vanilla Zustand stores, a trace recorder, math/flight utilities, and an optional React binding layer; zero @wabbit/* dependencies.

Install
  1. Add our registry to the .npmrc at the root of your project. Free packages install without a token.

    @wabbit:registry=https://npm.wabbit.com/
  2. Then install:

    npm install @wabbit/tome-engine

Overview

@wabbit/tome-engine

A domain-neutral real-time runtime substrate: a Three.js scene/actor loop, an input pipeline (devices → bindings → profiles → transforms), vanilla Zustand stores (identity/settings/score), a trace recorder, math/flight utilities, and a thin optional React binding layer. domain layer per root ARCHITECTURE.md — zero @wabbit/* dependencies. Renamed from @wabbit/tome-games in 0.1.1 (no bound consumers at rename time) because "games" undersold its reuse for any real-time web-dev surface — see the Games Layer Design spec Amendment A1.

Install

pnpm add @wabbit/tome-engine three

| Peer | Range | Notes | |---|---|---| | three | >=0.180 | required — the only non-optional peer | | react / react-dom | >=19.0.0 | optional — only needed for ./react | | zustand | >=5 | optional in the manifest, but needed at runtime by `.`, `./state` and `./react` — the /state stores are built with zustand/vanilla at module load, the root barrel re-exports /state, and ./react imports useStore. Only ./core, ./flight, ./input, ./trace and ./math load without it; if you import the root barrel, install zustand |

60-second quickstart

A minimal scene and its React mount. For a fuller, playable example — keyboard/mouse/gamepad input through resolveInput, a score store and a createTraceRecorder trace — see the flight chapter of tome-starter's /showcase/webgl (src/app/(frontend)/showcase/webgl/RingRunScene.ts and EngineRun.tsx):

// OrbitScene.ts — vanilla scene, no React
import * as THREE from 'three'
import { GameScene } from '@wabbit/tome-engine/core'
import type { ResolvedInputState } from '@wabbit/tome-engine/core'

// The type parameter is the PROGRESS shape this scene emits to subscribers — not the input type.
type OrbitProgress = { angle: number }

export class OrbitScene extends GameScene<OrbitProgress> {
  private angle = 0
  private cube = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial())

  protected buildScene() {
    // called by start(); the base class owns renderer, scene, camera and cameraRig
    this.scene.add(this.cube)
    this.cameraRig.position.set(0, 0, 5)
  }
  protected onTick(dt: number, _input: ResolvedInputState) {
    // every animation frame; dt in seconds, clamped at 50 ms
    this.angle += dt
    this.cube.rotation.y = this.angle
    this.emitProgress(this.buildProgress())
  }
  protected buildProgress(): OrbitProgress {
    return { angle: this.angle }
  }
}
// WebglShowcase.tsx — React mount
'use client'
import { useGameScene, GameCanvas } from '@wabbit/tome-engine/react'
import { OrbitScene } from './OrbitScene'

export function WebglShowcase() {
  // The factory receives the mounted <canvas>; the hook calls start() after mount and dispose() on unmount.
  const { sceneRef, scene } = useGameScene((canvas) => new OrbitScene(canvas))
  return <GameCanvas canvasRef={sceneRef} style={{ width: '100%', height: '100%' }} />
}

useGameScene runs the factory once, when the canvas mounts — a later change to the factory (or to its captured props) is ignored. To rebuild the scene, change the component's key. Pass { readResolved: () => resolvedInput } as the second argument to feed input each frame; without it every tick receives empty input. Outside React, the same lifecycle is scene.start({ readResolved, onComplete }), scene.subscribe(listener) (returns an unsubscribe function) and scene.dispose().

// Telemetry — one recorder per run, fed once per tick, quantized on finalize
import { createTraceRecorder } from '@wabbit/tome-engine/trace'

const recorder = createTraceRecorder() // DEFAULT_SAMPLE_RATE_HZ = 20
const position = recorder.register('position') // channel array, same reference accumulated in place

function onTick(elapsedSeconds: number) {
  recorder.feed(elapsedSeconds, (channels) => channels.position!.push(...currentPosition))
}
const finalized = recorder.finalize(totalDurationSeconds) // FinalizedTrace — quantized channels

Public API

| Subpath | Key exports | Description | |---|---|---| | . (root) | Everything below except /react | Convenience barrel — `/react` is deliberately NOT re-exported here so vanilla (non-React) consumers don't pull React into their bundle | | ./core | GameScene<P>, Actor, createCameraRig(fov?, near?, far?), disposeObject3D() / disposeScene (+ ResolvedInputState type) | Scene/actor lifecycle base classes — GameScene subclasses implement buildScene(), onTick(dt, input) and buildProgress(); Actor subclasses implement step(input, dt) | | ./flight | Ship, FULL_CONTROL_MASK, controlMask, maskAngularInput, stepAngularVelocity, stepLinearVelocityScalar, STRAFE_FLIGHT_CONFIG, STRAFE_ROLL_ACCELERATION, FREE_FLIGHT_CONFIG, FLIGHT_FOV_BASE, deriveFlightConfig, applyUnifiedMouseLook (+ AngularVelocity, AngularInput, ControlMask, FlightModelConfig, FlightMode, ShipPhysical types) | Flight-model primitives — canonical source for ControlMask/FULL_CONTROL_MASK (root barrel re-exports these from here, not from /math, since the names collide) | | ./input | Full input pipeline (see breakdown below) | Device-agnostic input resolution | | ./state | identityStore, createIdentityStore, getPlayerId, LocalIdentityProvider; settingsStore, createSettingsStore, getDifficulty; scoreStore, createScoreStore; normalizeScore, currentScoreVersion, defineNormalizer, SCORE_SPECS | Vanilla Zustand stores (createStore, not the React hook create) | | ./trace | TraceRecorder, createTraceRecorder(), DEFAULT_SAMPLE_RATE_HZ, quantize3, quantizeArray, SampleWriter, FinalizedTrace | Telemetry sample recording + quantization | | ./math | clamp, clamp01, clampSigned, lerp, sign, angleWrap, OneEuroFilter, integrateLookOrientation, attitudeFromQuaternion, computeShipAccel, smoothShipAccel, clampToBox | Pure numeric utilities (generic ControlMask also lives here — root barrel prefers /flight's version) | | ./react | useGameScene(factory, opts), useResolvedInput(store), useScore(scenarioId, difficulty?), usePlayerId(), useIdentityProvider(), GameCanvas | The only place React appears in the package — useGameScene mounts/disposes a GameScene on a canvas ref; useResolvedInput subscribes to a consumer-owned resolved-input Zustand store; useScore subscribes to the shared score store for a scenario; usePlayerId reads the reactive player ID off the identity store; useIdentityProvider swaps the identity provider at runtime and returns [provider, setProvider]; GameCanvas is a pre-styled <canvas> mount helper |

/input pipeline breakdown

Four stages, each its own directory under src/input/, composed by the consumer (there is no single resolveInput() god-function — types.ts exports the shared Action/AxisAction/ButtonAction/Profile/ResolvedInputState contracts every stage speaks):

  • `devices/` — raw device readers: gamepad.ts, keyboardMouse.ts, pointerLock.ts (mouse-look via the Pointer Lock API), webhid.ts (HID devices — flight sticks/throttles)
  • `bindings/` — resolver.ts maps raw device state to Actions per a Profile's AxisBinding/ButtonBinding entries; listener.ts/listener-detect.ts handle live rebind-capture; labels.ts renders a binding as a human-readable string
  • `profile/` — defaults.ts (DEFAULT_MOUSE_SETTINGS and friends), calibration.ts, storage.ts (persist a user's rebinding + calibration to ./state's settingsStore)
  • `transforms/` — curve.ts (response-curve shaping per AxisTransform.curve: CurveSpec), deadzone.ts, pipeline.ts (composes curve + deadzone into the final axis value)

Server / client posture

./react is the sole .tsx/React-bearing module and declares 'use client' — it touches useRef/useState/useEffect and DOM APIs (HTMLCanvasElement), so it can only run client-side. Every other subpath (/core, /flight, /input, /state, /trace, /math) is plain TypeScript with no React and no browser-only API beyond what Three.js itself requires at runtime — they're importable from a build script, a Node test, or a Server Component that only needs the pure math/state helpers (though instantiating a real GameScene still needs a DOM <canvas> to hand Three.js, so that part is practically client-only regardless of the module's own directive).

Testing

pnpm --filter @wabbit/tome-engine test runs the Vitest suite. /math, /flight, /input transforms and bindings, /state and /trace are pure enough to test in Node without a canvas; the stores are vanilla, so tests create a fresh one with createIdentityStore() / createSettingsStore() / createScoreStore() instead of sharing the module singletons. GameScene needs a real WebGL canvas, ResizeObserver and requestAnimationFrame, so test scene logic by extracting it into actors or pure functions rather than instantiating a scene in Node.

Extending

New subsystems get their own subpath (mirroring /flight, /trace) rather than growing an existing one — the root barrel's explicit re-export list means adding a subpath doesn't silently change what "vanilla" consumers pull in. If a new symbol name collides across subpaths (as ControlMask does between /flight and /math), the root barrel picks one canonical source and documents the collision inline (see the comment above the /flight export block in src/index.ts) rather than letting whichever import happens to run last silently win.

Design history

  • Originally published as @wabbit/tome-games, then renamed to @wabbit/tome-engine: the "games" name implied games-only and hid this package's reuse as a domain-neutral real-time runtime substrate (loop / input / math / state / trace) for rich web-dev generally. No bound consumers existed at rename time, so the tome-games name was freed for a future fork-and-own game starter (see CHANGELOG.md).

Exports

  • @wabbit/tome-engine
  • @wabbit/tome-engine/core
  • @wabbit/tome-engine/input
  • @wabbit/tome-engine/state
  • @wabbit/tome-engine/trace
  • @wabbit/tome-engine/math
  • @wabbit/tome-engine/flight
  • @wabbit/tome-engine/react

Changelog

v0.2.3patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.2.2patch

e3c58e0: README: the quickstart no longer claims to mirror the starter's old `OrbitScene` showcase (replaced by the scroll-story page); it now points at the starter's playable flight chapter (`RingRunScene.ts`, `EngineRun.tsx`) as the fuller example.

  • e3c58e0: README: the quickstart no longer claims to mirror the starter's old `OrbitScene` showcase (replaced by the scroll-story page); it now points at the starter's playable flight chapter (`RingRunScene.ts`, `EngineRun.tsx`) as the fuller example.
v0.2.1patch

68a6e0c: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata. The HOSAS slots in `defaultProfile()` and `virpilHosasProfile()` now use placeholder device IDs instead of two specific stick models' IDs; real devices register at runtime and are bound from the bindings screen. `virpilHosasProfile()` is now labelled "Dual-stick HOSAS".

  • 68a6e0c: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata. The HOSAS slots in `defaultProfile()` and `virpilHosasProfile()` now use placeholder device IDs instead of two specific stick models' IDs; real devices register at runtime and are bound from the bindings screen. `virpilHosasProfile()` is now labelled "Dual-stick HOSAS".
v0.2.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.
  • 0836ef5: dist now raw-Node loadable: relative specifiers get explicit extensions post-build. `build` gains `&& node ../../scripts/fix-dist-extensions.mjs --strict` as its last step, joining the 13 packages that already ran it. tsup builds `bundle: false` and emits relative specifiers exactly as the TypeScript source wrote them — extensionless — which bundlers resolve and raw Node does not (ESM `ERR_MODULE_NOT_FOUND`; CJS worse, `require('./x')` finds the ESM `.js` twin and Node 22+ `require(esm)` then dies on that file's own extensionless import). Every consumer outside a bundler hit this: the payload CLI under plain node, `generate:types`, `generate:importmap`, ops scripts, codegen tools. No source changes, no API changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable. Two supporting changes made the wiring possible, both in repo scripts rather than package source. `fix-dist-extensions.mjs` now skips bundler-asset specifiers (`.css`, `.module.css`, `.scss`, fonts, images, shaders) by explicit extension allowlist instead of reporting them as unresolvable — that single gap is why the 13 prior adopters were exactly the 13 packages that ship no CSS, since `--strict` exited 1 on any package with a relative stylesheet import. Dotted MODULE names (`./config.meta`, `./x.variants`, `./y.demo`) are deliberately NOT treated as assets and still get `.js`/`.cjs` appended. `assert-node-loadable.mjs` gained the matching carve-outs so the new repo-wide CI gate reports real defects only: a resolution failure whose path lands under `node_modules` is a peer SKIP (next@15 has no exports map, so `next/image` fails as an absolute path), and a bundler-asset load failure is an environmental SKIP (CJS surfaces it as `SyntaxError: Unexpected token '.'` raised from inside the stylesheet). Verified before/after on four packages built one at a time: print 8 FAIL → 0, readout 22 FAIL → 0, ai 3 FAIL → 0, gamification 2 FAIL → 0 (its failure was the other signature — a `directory import` missing `/index`). cop was already clean on a fresh build, so the audit's "27 of 46 fail" figure includes at least one package whose local dist was merely stale.
  • 73081e6: Manifest metadata: `homepage`, `bugs`, `engines`. All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change. - `homepage` deep-links to that package README on GitHub (`.../tree/main/packages/<dir>#readme`). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want. - `bugs.url` points at the repo issue tracker, so a paying customer has a place to report a defect that is not email. - `engines.node` is `>=22`, matching the root `engines` and `.nvmrc` set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for `node --test`, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version. The forcing function ships with the change: `scripts/assert-manifest-metadata.mjs` (root `pnpm assert:manifest-metadata`, wired into `platform-discipline.yml` beside `assert:license-metadata`) fails when any publishable manifest lacks `description`, `repository.directory` matching its own folder, `homepage`, `bugs`, `engines.node` equal to the repo floor, `license`, `files` or `sideEffects`. It reported 138 violations before this change and 0 after.
v0.1.2patch

26dfa07: `vitest run` exited 1 with "No test files found" in these two packages — neither has any test files yet. Added `--passWithNoTests` to the `test` script so CI doesn't fail on an empty suite. Script-only change; no runtime behavior changed. Both packages still need real test coverage added (tracked separately, not fixed here).

  • 26dfa07: `vitest run` exited 1 with "No test files found" in these two packages — neither has any test files yet. Added `--passWithNoTests` to the `test` script so CI doesn't fail on an empty suite. Script-only change; no runtime behavior changed. Both packages still need real test coverage added (tracked separately, not fixed here).
v0.1.1patch

6bb2f5a: Rename `@wabbit/tome-games` → `@wabbit/tome-engine`. The package is a domain-neutral real-time runtime substrate (loop / input / math / state / trace) — the "games" name implied games-only and hid its reuse for rich web-dev. No bound consumers at rename time; the `tome-games` name is freed for the fork-and-own game starter. See the Games Layer Design spec Amendment A1 (2026-05-31).

  • 6bb2f5a: Rename `@wabbit/tome-games` → `@wabbit/tome-engine`. The package is a domain-neutral real-time runtime substrate (loop / input / math / state / trace) — the "games" name implied games-only and hid its reuse for rich web-dev. No bound consumers at rename time; the `tome-games` name is freed for the fork-and-own game starter. See the Games Layer Design spec Amendment A1 (2026-05-31).