A universal rule-based simulation sandbox. Not an algorithm visualizer — a generic engine in which cellular automata, flocking agents, graph search, and CPU schedulers are all the same program shape, expressed as data.
npm install
npm test # 178 tests
npm run dev # http://localhost:3000 (webpack -> .next)
npm run dev:turbo # http://localhost:3000 (turbopack -> .next-turbo)
npm run clean # wipe both build dirs
# Gallery API (optional — the Build tab works without it)
npm run api:setup # one-time: venv + deps
npm run api # http://localhost:8000
npm run api:test # 24 testsMIT licensed. Requires Node 20+ and, for the gallery, Python 3.11+. No Docker, no Rust.
packages/engine imports no simulation and no renderer. A family declares everything it needs in
one object and hands it to registerPlugin:
registerPlugin({
id: 'drone-swarm',
name: 'Drone Swarm',
topologies: [myTopologyFactory], // new "who are my neighbours?" implementations
renderers: [myRenderer], // new ways to draw
scenarios: [droneSwarm], // parameterised simulations
});The two former hardcoded switches — buildTopology() in world.ts and drawFrame()'s
switch (hint.kind) — are now registry lookups. Grid, Space, Graph and Null register themselves the
same way a stranger's plugin does; they get no privilege the core withholds.
Renderers need a canvas, and the engine runs in a worker with no DOM. So the engine never types them:
it hands them to a sink that @simulab/render installs on the main thread. In the worker no sink
exists and renderers are ignored, which is why one plugin module can be imported on both sides.
packages/engine/tests/plugin.test.ts defines a ring topology inside the test file, registers it,
and drives it with an ordinary rule through the real compiler and scheduler. That is the claim, made
falsifiable.
Eight plugins ship: Cellular Automata, Agent-Based, Graph Algorithms, Scheduling, Particle Physics,
Drone Swarm, Traffic, Network Routing. The last four were added without one line changing in
packages/engine/src.
packages/scenarios/tests/golden.test.ts pins the exact state hash of every simulation, captured
before the refactor. A state hash covers every live component value, the globals, the arrays, the
clock and the RNG position — so one bit of behavioural drift anywhere fails the test. Conway's Game
of Life produces the same hash today that it did before the engine was rebuilt around it.
Exactly one hash changed, deliberately, and the reason is recorded in the file: buildGridGraph
skipped an RNG draw for unweighted graphs, so BFS got a different maze from Dijkstra at the same
seed — and seed 7 walled BFS's source in, leaving it to visit one node. All three graph searches now
share a maze per seed, which is what you want when comparing them.
Nothing in the engine knows what "Conway's Game of Life" or "Dijkstra" is. Both are rule sets over two abstractions:
One topology interface. "Who are my neighbours?" has the same answer shape for a grid cell, a
boid in continuous space, and a graph node. GridTopology, SpatialHashTopology, and
GraphTopology all implement it, so count(neighbors(moore(1)), alive) is one code path across all
three. A fourth query kind, all, treats "every live entity" as just another neighbourhood — which
is what lets the CPU scheduler pick from its ready queue without any special support.
Two schedulers. Tick-based (fixed dt, sweep every entity) and discrete-event (pop the next
(time, sequence) event). Neither emulates the other: 100k cells as 100k events per tick would be
pathological, and a graph search on a fixed timestep wastes most ticks.
Spatial queries are capabilities, not features. A topology may declare position(),
withinRadiusAt() and raycast(). From those the IR gets distance, within, nearest and
raycast — and a grid cell answers them exactly as a drone does, because both can say where they
are. A topology that cannot must say so loudly rather than return a plausible wrong number.
Movement and physics are macros, not IR. integrate, clearForces, gravity, drag,
springTo, seek, flee, clampSpeed, bounceBounds, wrapBounds each return Action[] built
from existing nodes. The compiler gained nothing. Neither would yours.
The event bus is not the event queue. EventQueue is simulation-time and drives the model;
EventBus is wall-clock and drives instrumentation. Listeners are strictly observers — attaching an
analytics recorder cannot change a run, and there is a test that proves the state hash is identical
with and without one.
The payoff falls out of the second one. The event heap is Dijkstra's priority queue. A rule
emits settle at time = tentative distance; popping in time order settles nodes in distance
order. There is no separate priority-queue implementation anywhere in the codebase.
// packages/scenarios/src/graph.ts — Dijkstra, in full
onEvent('dijkstra-settle', 'settle', [
setSelf('settled', lit(1)),
forEach(edges(), [
let_('nd', add(self('dist'), edgeW)),
when_(lt(local('nd'), other('dist')), [
setOther('dist', local('nd')),
setOther('prev', selfId),
emit('settle', { self: otherId, at: local('nd') }), // heap key = new distance
]),
]),
], { when: eq(self('settled'), lit(0)) }) // drop stale queue entriesSwap at: local('nd') for at: add(local('nd'), h) and you have A*. Swap the dispatcher's argmin
key from other('enq') to other('deadline') and Round Robin becomes EDF. That is what "what
happens if I change one rule?" looks like when rules are data.
The Build tab is an editor over the same IR. You write a simulation in the DSL, get static
analysis as you type, and run it. Nothing about it is second-class: six of the starting templates
mirror a built-in scenario, and templates.test.ts proves each one produces a bit-identical state
hash, tick for tick, against the TypeScript scenario it mirrors. Authoring in the editor and
hand-writing a scenario compile to the same program.
describe('Cyclic CA', 'Each cell advances when a neighbour already holds the next state.')
schema({ state: u8({ doubleBuffered: true }) })
grid(96, 96, { wrap: true })
globals({ states: 12 })
const next = mod(add(self('state'), lit(1)), glob('states'))
rule('advance', [setSelf('state', next)], {
when: anyOf(neighbors(moore(1, true)), eq(other('state'), next)),
})
seedRandomInt('state', 0, 12)
renderGrid('state', ['#0b0f19', '#1e3a8a', /* … */])A compiled simulation is pure data — schema, topology, rules, setup ops, render hint. That is what makes the gallery safe: publishing sends JSON, and running someone else's simulation loads their IR straight into your engine. Their source is never evaluated. Only your own drafts are ever compiled, and only in the worker.
Setup had to become declarative for this to hold (seedRandom, seedCells, seedSpawn,
seedEmit), since a setup(world) callback cannot be serialized.
On the sandbox, honestly: compiling your own source uses new Function with dangerous globals
shadowed to undefined, inside a DOM-less worker. That is a footgun guard, not a security boundary
— eval cannot even be shadowed in strict mode, and ({}).constructor.constructor reaches the real
global. It is sufficient because you only ever compile source you wrote. A real boundary needs
QuickJS-in-WASM or a sandboxed iframe. Separately, no sandbox can stop while(true): rules cannot
loop forever (the IR has no unbounded loops), but a script body can, so the main thread watchdogs
every compile and restarts a wedged worker.
apps/api is FastAPI + SQLite (no Docker, no Postgres — neither is installed here, and neither is
needed at this size). Save, browse, and fork.
There is no account system. The browser mints one opaque owner token; possession of it authorises editing. Only its SHA-256 is stored, so a database dump grants nobody rights. A stranger editing your simulation gets 403, not 404 — the resource exists, they just don't hold its token; they can fork it instead.
The server treats a simulation as inert: it never compiles the DSL source and never evaluates the IR. Both are columns. Validation is about shape and size, not safety.
Everything targets one JSON IR (packages/engine/src/ir/types.ts). A fluent builder constructs it;
the executor compiles it to closures; the static analyser reads it. Because rules never execute host
code, we can answer "will this terminate?" before running a tick:
| Diagnostic | Catches |
|---|---|
zero-delay-cycle |
An event that re-emits itself at the same timestamp — the clock never advances |
guard-reads-local |
A when guard reading a local the body binds (guards run first; it reads 0) |
unbound-local |
A let that was never bound |
unknown-name / unknown-array |
A rule touching something that does not exist |
write-conflict |
Two same-priority rules writing one component |
guard-reads-local exists because that bug was written, shipped into a scenario, and only caught by
a test. The analyser now catches the whole class.
Every run is bit-reproducible for a given seed: xoshiro128** seeded via SplitMix32 (pure 32-bit
integer ops, no BigInt), fixed iteration order, (time, sequence) event tie-breaking, and
Math.random banned engine-wide.
Time travel depends on it. Rather than journaling reverse-deltas — useless for a CA where every cell
changes every tick — the TimeMachine snapshots a keyframe every K ticks and re-simulates
forward to reach anything in between. Snapshots carry the RNG state, the event heap, and the
entity free lists, not just component banks. When the cache exceeds its budget it thins
logarithmically: recent history stays dense, distant history gets sparse.
Correctness relies on read/write phase separation. Double-buffered components are read from one bank and written to the other, so every cell sees the previous generation regardless of sweep order. That is what makes Life correct without any ordering discipline in the rules.
packages/engine headless, zero DOM deps, imports no simulation
registry.ts the extension seam
plugin.ts SimulationPlugin + registerPlugin + renderer sink
store.ts SoA typed arrays, double buffering, generational lifecycle
topology.ts Grid / Space / Graph / Null + capabilities + registry
events.ts binary min-heap keyed by (time, sequence)
bus.ts observability events; listeners cannot perturb a run
analytics.ts rewind-aware series, built on the bus
physics.ts movement primitives (macros over the IR)
ir/types.ts the canonical rule IR (incl. distance / nearest / raycast)
ir/compile.ts IR -> closures
world.ts both schedulers; topology from the registry
timetravel.ts keyframes + deterministic replay
analysis.ts static analysis over the IR
dsl.ts authoring source -> CustomSpec data
packages/render renderer registry: grid / agents / graph / timeline
packages/scenarios 8 plugins, 21 scenarios, 8 editor templates — all pure IR
apps/web Next.js UI; engine runs in a Web Worker
src/sim/host.ts command protocol, testable without a browser
apps/api FastAPI + SQLite gallery
178 TypeScript tests + 24 Python tests. The ones that matter are the ones that could actually fail:
-
Template equivalence. Each mirroring template is compiled from DSL source and run beside its TypeScript scenario, comparing state hashes at every tick. Drift is a test failure.
-
Round-trip fidelity. A simulation compiled locally,
POSTed to the API, and fetched back anonymously replays to the identical state hash — verified against a live server. -
Ownership. A stranger cannot update or delete; forking transfers ownership; tokens are stored only as hashes.
-
Reference cross-checks. Engine Dijkstra vs a plain O(V²) Dijkstra on random graphs; BFS vs a textbook queue; Rule 30/90/110 vs an independent elementary-CA implementation; Life vs a nested-loop implementation over 25 generations. A* must find the same optimal cost as Dijkstra while settling strictly fewer nodes.
-
Golden masters. A glider translates exactly (1,1) every 4 generations, and (5,5) after 20.
-
Hand-computed schedules. RR/EDF/Priority finish times worked out by hand, plus the invariant that the CPU never idles (
max(finish) == sum(burst)). -
Topology conformance. The spatial hash returns exactly the brute-force radius set, across four cell-size/radius combinations.
-
Determinism & time travel. Same seed ⇒ same state hash.
seek(k)lands on byte-identical state to tickkon the forward pass — through births, deaths, and keyframe eviction. -
Protocol integration. The worker's command protocol driven end-to-end without a browser.
Measured on the interpreter backend (Node 25, ms/tick, median of 5):
| Workload | Entities | ms/tick | Throughput |
|---|---|---|---|
| Game of Life | 100k | ~18 | 5.4M entity-updates/s |
| Game of Life | 1M | ~200 | 5.0M entity-updates/s |
| Boids | 10k | ~21 | — |
| Dijkstra (90k nodes) | 86.7k events | — | ~400k events/s |
Two findings worth recording. Dijkstra was originally 40× slower because a metrics field called
liveCount() scanned every entity on every step — an O(n) tax per event. And the spatial-hash
memo (flocking asks for the same neighbourhood ~7×/tick at 2 distinct radii) was A/B'd rather than
assumed: 32.8 → 21.3 ms/tick at 10k boids. It is only enabled when position components are
double-buffered, which is exactly when the read bank is frozen for the tick — detected, not assumed.
Scoped out deliberately, in favour of depth:
- Visual node builder. Chosen as the next step: React Flow over the same IR the editor produces, so both surfaces edit one artifact. The IR was designed for this and already round-trips to JSON.
new Functioncodegen backend. The IR compiles to closures. Generating JS source per rule (hoisting the entity loop inside the generated function) is worth an estimated 5–20× on CA. The interpreter would stay as the reference implementation for differential testing.- SharedArrayBuffer transport. Frames are structured-cloned with transferable buffers. SAB needs COOP/COEP headers and a seqlock over triple buffers to avoid tearing; the current copy costs ~100KB/frame for a 100k grid, which is not the bottleneck yet.
- Rust/WASM. Skipped by choice — cargo is not installed, and the codegen backend above delivers a comparable speedup with no toolchain.
- PixiJS / WebGPU / 3D. Grids render via a single
putImageDatablit, which beats 100k sprite draws; agents and graphs use batched Canvas2D paths. Instanced sprites only pay off above ~20k agents. - Real auth, live collaboration, AI assistant. The gallery uses opaque owner tokens, not accounts. Collaboration needs websockets on top of the existing API. The AI assistant would pair naturally with the DSL (natural language → rules), and needs an API key.
- A true script sandbox. See the note above: the current guard is not a security boundary.
The studio is loaded client-side only (next/dynamic with ssr: false); the server prerenders just
a splash. Nothing here benefits from SSR — there is no content to index, and the first meaningful
paint is a simulation that cannot exist before the worker boots.
The other reason is defensive. Browser extensions (password managers, form fillers) stamp attributes
such as fdprocessedid onto <button> and <input> elements after the server HTML arrives and
before React hydrates. React then diffs the mutated DOM against its own render and reports a
mismatch it "won't patch up". suppressHydrationWarning cannot fix that — it covers one element's
own attributes and text, not a subtree of buttons. Prerendering no interactive tree does. The build
output is checked for this: zero <button> elements reach the server HTML.
Webpack and Turbopack write incompatible client-reference manifests into .next. Run one after
the other and the second reads the first's chunk ids, which surfaces as:
Could not find the module
.../builtin/global-error.js#defaultin the React Client Manifest. This is probably a bug in the React Server Components bundler.
It is not a bundler bug — it is a stale cache. Two guards:
distDirisprocess.env.NEXT_DIST_DIR || '.next', anddev:turbopoints Turbopack at.next-turbo. The two can never share a manifest.app/global-error.tsxandapp/error.tsxexist. Beyond giving real error UI, defining a global error boundary stops Next reaching for the builtin whose client reference goes missing.
If you ever see it anyway: npm run clean.
Relative imports inside packages/ are extensionless (./store, not ./store.js). The ESM-correct
.js form needs webpack's resolve.extensionAlias to map back to .ts, and Turbopack has no
equivalent — so next dev --turbopack failed with Can't resolve './store.js' while next build
passed. Since Next 16 makes Turbopack the default, that would have become the default failure.
moduleResolution: "bundler" resolves extensionless imports under TypeScript, vitest, webpack and
Turbopack alike, so the specifiers changed and both config hacks were deleted. The tradeoff: these
packages can no longer be consumed by raw Node ESM without a bundler, which nothing does.
MIT — see LICENSE.