Reference

Architecture & philosophy

The structural decisions that shape Meep - pure ECS, zero allocation, source-available, and code-first - and why each one was made.

Meep is built around a short list of non-negotiable properties. This page names them, explains the reasoning, and points at the parts of the engine where each one shows up. It assumes you’ve already read the ECS overview and the getting started guide.

Pure ECS, zero allocation

The ECS overview covers the data-oriented architecture in detail. What’s worth adding here is why the two properties travel together.

A pure ECS gives you predictable memory layout: components live in flat typed arrays, component queries are O(1) cached, and a system iterating ten entities costs the same per-element as one iterating ten million. But predictable layout only helps if you don’t throw it away every frame. The moment you start allocating new objects in your hot path - a new Vector3() here, a new Array() there - you hand control back to the garbage collector. On mobile, that means jank you can’t tune away.

The zero-allocation rule closes the loop. Math operations write into out-parameters; they don’t return new objects. Particle systems re-use emission buffers rather than growing and discarding them. Component pools are pre-allocated at scene load. The result is a simulation that the GC mostly never sees: memory turns over in the handful of permanent pools, and the per-frame allocation rate is near zero.

These are not independent features. The ECS makes zero-allocation straightforward to enforce; zero-allocation makes the ECS layout worth having.

6,000+ runtime assertions

Meep makes heavy use of a single internal assert module - src/core/assert.js. It exports assert, assert.equal, assert.defined, assert.isInstanceOf, assert.greaterThan, and around twenty other variants. Every invariant the engine can express at call time is expressed: wrong component type passed to a method, index out of range, NaN fed to a physics solver, entity used after it’s been destroyed.

In development these throw immediately at the source of the violation. You get a stack trace pointing at the line that broke the rule, not at whatever crashed downstream.

In production none of them exist. Every example in the repository configures @rollup/plugin-strip to remove all assert.* calls at build time:

// vite.config.js (first-person example, and every other example)
import strip from "@rollup/plugin-strip";

export default defineConfig({
  plugins: [
    { ...strip(), apply: "build" },
  ],
});

The default pattern (assert.*) covers the full call surface. After stripping, the functions and their arguments are gone - the call sites compile to nothing. No wrapper overhead, no boolean guards, no dead branches. See the installation guide for the bundler-specific setup.

The flip side is that the assertions are only useful if you run a dev build. Shipping the un-stripped engine in production is not catastrophic - it still works - but it wastes bundle bytes and CPU on validation code that has no place in a shipped game.

~15,000 handwritten tests

The engine is held to roughly 15,000 tests, covering critical algorithms, edge cases, and architectural invariants - determinism, adversarial and shader-emulation suites among them. The spec files are excluded from the published npm package (the files field in package.json excludes src/**/*.spec.js, editor/**/*.spec.js and samples/**/*.spec.js), so they don’t bloat installs, and the counts above come from the engine’s own README rather than from anything you can recount after npm install.

The runner is vitest, reading the project’s Vite config - there is no separate config file. CI checks the generated declarations first:

"scripts": {
  "test": "vitest run",
  "test:ci": "node scripts/check-types.mjs && vitest run --coverage --reporter=default --reporter=junit --outputFile=junit.xml"
}

The intent behind “handwritten” is that these are not snapshot tests or generated fuzz runs - they’re authored assertions about specific behaviors that have been wrong at some point, or that encode a contract the engine depends on being true. Tests for the BVH traversal, the physics solver substep arithmetic, the ECS query cache invalidation, and the binary serializer round-trips are in this category.

Tests that need a GPU don’t get one. Shade ships SoftwareGPUDevice (src/shade/device/mock/SoftwareGPUDevice.js) - a validating software WebGPU device that checks bindings, passes, resources and copies without executing WGSL or rasterizing anything - so renderer specs run headless in CI.

Source-available

The full engine source ships under src/ inside the npm package. When you run npm install @woosh/meep-engine, you receive approximately 6,000 JavaScript source files, plus the editor under editor/. There is no prebuilt bundle of the engine - no main field, no module field - and build/ holds exactly two worker bundles (the image decoder and the terrain worker) that the engine loads for itself. There are no compiled blobs, no obfuscated code, no WASM modules that wrap an opaque binary.

This matters in two practical ways:

Debuggability. When something in the engine behaves unexpectedly, you can read the code. Source maps point at real files on disk. You can set a breakpoint in the engine’s EntityManager.js or PhysicsSystem.js the same way you’d break in your own code.

No lock-in. If you need to understand why a query returns a particular result, or how the physics solver handles a degenerate contact, the answer is in the source you already have. You don’t need to file a support ticket and wait.

The license is proprietary - see FAQ for the distinction between source-available and open source. But the distribution model is deliberately transparent: no black boxes.

JSDoc-typed JavaScript with generated TypeScript declarations

The engine is authored in JavaScript with JSDoc type annotations. TypeScript declarations are generated by the build (npm run generate-types) and ship alongside the source. Every class, function, and type in the public API has a corresponding .d.ts file that TypeScript tooling picks up automatically.

The reason for JavaScript rather than TypeScript as the authoring language is explained in the FAQ: TypeScript can consume JavaScript cleanly, but JavaScript cannot consume TypeScript without a compile step. Authoring in JS keeps the engine usable from both worlds without friction. JSDoc gives most of the same editor benefits - autocomplete, inline type errors, documentation on hover - while the source stays as plain JavaScript that any bundler can handle without a TS build step.

Fine-grained ES modules

The engine is distributed as approximately 6,000 individual ES modules. There is no monolithic root export - you import exactly what you need by path:

import { EntityManager } from "@woosh/meep-engine/src/engine/ecs/EntityManager.js";
import { Transform64 }   from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";

Tree-shaking removes everything you don’t reference. A demo that does nothing but import a lerp utility compiles to four lines. A demo that pulls in the full renderer, physics, AI, and terrain system still ships a smaller runtime than most monolithic alternatives, because every module is individually droppable.

The granularity also makes it straightforward to find the code behind any given feature: one module = one concept, named to match what it does.

Dependencies and peers

The engine has two runtime dependencies and one peer. There is no renderer dependency of any kind: Meep renders through Shade, its own WebGPU renderer, which lives in-tree under src/shade/.

"dependencies": {
  "opentype.js": "1.3.3",
  "robust-predicates": "3.0.2"
},
"peerDependencies": {
  "dat.gui": ">=0.7.0"
}

dat.gui backs the dat.GUI debug UI (DatGuiController, DatGuiUtils, the built-in OptionsView, the editor’s type editors) and one in-tree prototype script - import none of those and it never reaches your bundle, but it carries no optional flag, so npm installs it alongside the engine. The harness FPS counter is the engine’s own FrameRateView and needs no package.

Three.js is not a dependency, a peer, or an optional extra: no module under src/ imports it.

Node’s floor is >= 24. See installation for bundler details.

Code-first, editor optional

Meep is code-first: nothing about the engine assumes a GUI. There is no proprietary project file, no scene format you can only produce by clicking, no component you have to open a tool to configure. An entity is created by calling new Entity().add(component).build(ecd). A scene is a function that builds entities. A level is a system that knows when to load and unload scenes. The state of your world at any point in time is the result of running your code.

That is the trade, and it is deliberate: you have to understand what you’re building well enough to write it, and in exchange your project is a directory of plain source files, version-controlled like the rest of your code, reviewable in a diff, and rebuildable from scratch.

There is, however, an editor. It ships in the package at editor/, exported through "./editor/*", as ordinary source alongside the engine - about 175 modules of it. It is a scene editor you attach to a running engine, not a shell you launch the engine from.

You turn it on with one call:

import { enableEditor } from "@woosh/meep-engine/editor/enableEditor.js";

const control = enableEditor(engine);

control.enable();   // attach now
// control.disable(); control.toggle(); control.editor

enableEditor(engine, initialization?) returns { enable, disable, toggle, editor }. The editor getter constructs the Editor on first access, calls initialize(), and then runs your initialization(editor) callback - which is where you register type editors or seed editor.meshLibrary. Attach and detach are queued in request order, so a disable() issued while an enable() is still settling waits for it rather than tearing down a half-attached editor.

enableEditor also binds the NumLock key to toggle the moment you call it, via engine.devices.keyboard, and logs a console.warn saying so. That binding is unconditional and there is no option to suppress it - if NumLock means something in your game, don’t call enableEditor; construct Editor and drive attach/detach yourself.

What Editor.attach(engine) needs from the engine it’s given:

RequirementWhy
engine.isEngine === trueasserted on entry
engine.entityManagerit adds an EditorEntitySystem and builds its own camera entity into the dataset
engine.viewStackthe editor shell view is pushed onto it
engine.devices.keyboardonly for enableEditor’s NumLock binding; EditorKeyMap listens on window directly

Attaching also disables InputControllerSystem and TopDownCameraControllerSystem for the duration, so game input and game camera controllers do not fight the editor’s own camera and gizmos; both are restored on detach.

A graphics-less engine is supported. The selection visualizer records into the frame the renderer is about to draw, so attach guards it with engine.graphics !== null and skips it when there is nothing rendering; everything else - selection, actions, undo, scene import/export - attaches normally. That is what makes the editor usable against a headless simulation.

Two practical notes. The editor’s stylesheets are SCSS (editor/style/*.scss) and are not imported by Editor.js itself, so your bundler needs to handle SCSS and you import editor/style/editor.scss yourself - editor/prototypeEditorShell.js is the shipped worked example of a complete editor host, and it is worth reading before wiring your own. The editor’s keyboard map is fixed (Ctrl+Z/Y undo/redo, Ctrl+C/V, Ctrl+S save, X or Delete to remove, Shift+D duplicate, 1/2 to pick a tool, W/E/R translate/rotate/scale, F to frame the selection); unhandled keys fall through to the active tool.

Where to go next