Overview
The particle runtime (@test-cabinet/particle-runtime, in
packages/particle-runtime) is the shared TypeScript library that turns a
particle run’s produced system.json into a
playable effect by simulating it live — stepping its emitters and forces and
evaluating each particle’s life-curves every frame. It is the particle analogue of
@test-cabinet/voxel-runtime, with one
defining difference: a voxel rig is posed (its geometry arrives ready-made and the
runtime only resolves transforms), whereas a particle system is simulated — there is
no baked frame data, so the runtime integrates the effect forward from the authored
description. It is consumed by the in-repo particle viewer
that reviews particle runs and by real games that embed a produced effect, so the
simulation math lives in one place rather than being reimplemented per consumer.
Like the voxel runtime, it is a code-sharing library, not a component itself: it ships no service and runs in no process of its own.
The pure-core / bindings split
Section titled “The pure-core / bindings split”The package ships three subpath entries so a consumer takes only what it needs:
@test-cabinet/particle-runtime(the root) — the pure core: the contract types, the curve/gradient sampling, the deterministic PRNG (Rng), and theParticleSimulator, with no rendering dependency. A game with its own renderer, or a headless consumer (a test, a server), uses this alone. It captures render-ready particles as plain data (RenderParticle[]), so any renderer can draw them.@test-cabinet/particle-runtime/three— the three.js binding: aParticleSystemPlayerthat draws the simulator’s particles as a GPU billboard point cloud (additive blending by default, for fire/energy VFX; normal blending for smoke/debris).threeis a peer dependency (not bundled), so a consuming game shares its singlethreeinstance with the runtime.@test-cabinet/particle-runtime/canvas— the 2D-canvas binding: aParticleCanvasPlayerthat composites the same simulated particles as soft radial-gradient discs into aCanvasRenderingContext2D(lighter/additive by default). The canvas analogue of thethreebinding — same simulated state, a 2D raster path instead of billboards — for aparticle-2deffect that composites into a flat scene.
The tooling mirrors @test-cabinet/voxel-runtime
and @test-cabinet/run-record — a composite tsc -b
build. The core re-exports the one shared enum it needs —
InterpSpec, the F-curve interpolation — from the
generated run-record package, so there is a single source of truth for it (exactly as
the voxel runtime’s contract re-exports the rig types). The rest of the system.json
shapes are declared locally, matching the documented contract, until contract-codegen
emits a particle-system type.
The contract it loads
Section titled “The contract it loads”The runtime consumes exactly the one artifact a particle run produces — the
system.json emitted by the
particle binaries:
system.json— the whole authoredParticleSystem: itsdimensions(2planar /3volumetric), its boundingfield,durationMs,fps,loopintent, its emitters (each an emission source —point/disc/sphere/cone/box/edge— releasing particles at arateor as a timedburst, with per-particle lifetime/speed/direction and their spreads), the forces integrated into motion (gravity, drag, radial push, vortex, curl-noise turbulence, wind — global, with per-emitter overrides), the per-particle appearance over normalized life (size and opacity F-curves, a keyed color gradient, spin, velocity-stretch, an optional cross-asset sprite), and the sub-emitter links (a child system fired on a parent particle’sdeathor along itssteppath).
The system shape is governed by the documented particle contract, so a consuming game can rely on it the same way the review UI does.
Simulation
Section titled “Simulation”The core is framework-agnostic. Its central primitive is the ParticleSimulator,
which advances a system’s live state and captures a render-ready snapshot:
const sim = new ParticleSimulator(system, { seed, maxParticles });sim.step(dtMs); // integrate + age live particles, then emit over the windowconst particles = sim.capture(); // RenderParticle[] — appearance evaluated at each lifeEach step(dtMs) integrates and ages every live particle (firing sub-emitters and
removing the dead), then emits new particles over the elapsed window; capture()
evaluates each surviving particle’s appearance at its current normalized life (size,
opacity, color, velocity-stretch) into a RenderParticle the bindings draw. The
simulator also exposes clockMs (the monotonic play clock, advancing across loop
cycles), liveCount, isNonEmpty, and reset() (rewind and re-seed, re-firing any
zero-time bursts so frame 0 already carries them).
Stepping the system is main-thread work in the viewer, so the live count is
capped at 10,000 — the same
live-particle budget
the binaries enforce when the system is authored, mirrored here so a system.json
that predates the budget (or was written by hand) still cannot freeze the tab.
Spawns past the cap are dropped; maxParticles lowers it further for a constrained
client.
Turbulence is the one force whose cost is not proportional to anything visible.
Curl noise is the curl of a hash-based potential, and evaluating it per particle per
frame means hundreds of exact 64-bit hashes — cheap in native Rust, ruinous in
JavaScript. The lattice those hashes sit on is small, shared by every particle, and
constant over time, so CurlNoise memoizes it in a fixed-size open-addressed table
and reads only the six partial-derivative components the curl actually uses (rather
than three full potential evaluations per axis). Both are exact: the values are
bit-identical to the naive form, and to the Rust simulator’s, so a seeded play still
matches the binary’s. Together they take a turbulent system at the particle cap from
about 670 ms a frame to about 10 ms.
Determinism
Section titled “Determinism”Every random draw folds in a base seed. Pass a fixed seed to make a play
reproducible — the same effect every time, which is how the binary’s headless
preview renders — or omit it to let each play vary. This mirrors the voxel runtime’s
posing determinism: given the same input the output is stable.
Consuming a produced effect
Section titled “Consuming a produced effect”The runtime has two consumers, exactly like the voxel runtime:
- The review UI. The particle viewer mounts the
threebinding’sParticleSystemPlayerto replay a producedsystem.jsonfor a run, and the live asset view plays the in-progress system as it streams. A game and the review UI therefore simulate a produced effect identically. - A real game, via the manifest
packageskey.@test-cabinet/particle-runtimeis one of the shippable Test Cabinet runtime libraries (alongside@test-cabinet/voxel-runtime) an end-to-end case may request in itspackageslist. When a case declares it, the driver vendors it into the run repository as an in-repofile:dependency, so the built game canimport { ParticleCanvasPlayer } from "@test-cabinet/particle-runtime/canvas"to load a seeded, cross-assetsystem.json(for example a produced VFX burst) and simulate it live in-game — the same way the gallery plays it. The allowlist of shippable packages lives incrates/core/src/test_case.rs(SHIPPABLE_PACKAGES) and must stay in lockstep withscripts/stage-tcab-packages.mjs, which bakes them into the run image.
Status
Section titled “Status”Implemented in packages/particle-runtime. The pure core has no rendering dependency;
both the three and canvas bindings take three / the DOM canvas only in their
subpaths. It is consumed by the UI library’s particle viewer
and live asset view, and is publishable — via the manifest packages key — for games
that embed a produced particle effect.