Engine
Realtime & 3DPreviewDomain-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.
Get a free registry token from your credentials page. Every install from our registry needs one, free packages included.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKENThen 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 channelsPublic 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.tsmaps raw device state toActions per aProfile'sAxisBinding/ButtonBindingentries;listener.ts/listener-detect.tshandle live rebind-capture;labels.tsrenders a binding as a human-readable string - `profile/` —
defaults.ts(DEFAULT_MOUSE_SETTINGSand friends),calibration.ts,storage.ts(persist a user's rebinding + calibration to./state'ssettingsStore) - `transforms/` —
curve.ts(response-curve shaping perAxisTransform.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 thetome-gamesname was freed for a future fork-and-own game starter (seeCHANGELOG.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
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.
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.
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".
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.
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).
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).