Skip to content

rojenwai/SimuLab

Repository files navigation

SimuLab

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 tests

MIT licensed. Requires Node 20+ and, for the gallery, Python 3.11+. No Docker, no Rust.

Plugin architecture

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.

Not breaking the old simulations

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.

The idea

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 entries

Swap 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.

Inventing your own

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.

Gallery

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.

Rules are data, not code

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.

Determinism

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.

Layout

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

Verification

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 tick k on the forward pass — through births, deaths, and keyframe eviction.

  • Protocol integration. The worker's command protocol driven end-to-end without a browser.

Performance

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.

Not built

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 Function codegen 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 putImageData blit, 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.

A note on hydration

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.

A note on the two bundlers

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#default in 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:

  • distDir is process.env.NEXT_DIST_DIR || '.next', and dev:turbo points Turbopack at .next-turbo. The two can never share a manifest.
  • app/global-error.tsx and app/error.tsx exist. 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.

A note on module specifiers

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.

License

MIT — see LICENSE.

About

A universal, rule-based simulation platform for building, testing, visualizing, and analyzing algorithms, multi-agent systems, cellular automata, graph algorithms, scheduling, and custom computational models through an interactive visual sandbox.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors