The MIT-licensed particle runtime behind particlr — a
framework-agnostic simulation core plus a PixiJS v8 adapter. It plays back
.prt effect documents deterministically: the same document, seed, and
sequence of dt values always produce the same frames. The particlr editor
previews through this exact package, so what you tune is what you ship.
- Zero runtime dependencies in the core (
pixi.jsis an optional peer, used only by the/pixiadapter). - Node-safe core — no DOM, no
Math.random, no wall-clock reads. - Ships the JSON Schema for
.prt(@particlr/runtime/particle.schema.json).
npm install @particlr/runtime pixi.jspixi.js@^8 is a peer dependency (only needed if you use the /pixi adapter).
TypeScript note: if you compile with skipLibCheck: false, importing
@particlr/runtime/pixi may surface duplicate-identifier errors (TS2300/TS2403)
from pixi.js v8's bundled @webgpu/types colliding with TypeScript's own DOM
WebGPU declarations. That's an upstream pixi.js × recent-TypeScript issue, not
this package; the standard fix is "skipLibCheck": true (the common default).
This is the whole integration — load a .prt, step it each frame, render it:
import { Application } from "pixi.js";
import { parseParticle, Effect } from "@particlr/runtime";
import { PixiParticleRenderer } from "@particlr/runtime/pixi";
const app = new Application();
await app.init({ background: "#000", width: 800, height: 600 });
document.body.appendChild(app.canvas);
const doc = parseParticle(await (await fetch("boom.prt")).text()).doc!;
const fx = new Effect(doc, { seed: 1337 });
const view = new PixiParticleRenderer(fx);
view.container.position.set(400, 300); // where the effect plays
app.stage.addChild(view.container);
app.ticker.add((ticker) => {
fx.step(ticker.deltaMS / 1000); // advance the simulation
view.sync(); // copy state onto the Pixi particles
});A complete runnable version is live at particlr.brac.dev/sample — click the canvas to spawn effects through this exact package.
parseParticle(input: string | object): ParseResultParses (JSON string or object), migrates to the current schema version, and validates. Unknown fields are preserved for byte-stable round-tripping. Throws only if a string isn't valid JSON.
interface ParseResult {
ok: boolean;
doc: ParticleDoc | null; // null when !ok
errors: ValidationIssue[]; // { path, message, code? }
warnings: ValidationIssue[];
}serializeParticle(doc: ParticleDoc): stringEmits canonical JSON (declared key order, unknown keys preserved, 2-space
indent, \n endings, trailing newline). serializeParticle(parseParticle(text).doc)
equals text byte-for-byte for canonical input.
validateParticle(input: object): ValidationResult // { ok, doc, warnings } | { ok:false, errors, warnings }BUILTIN_TEXTURE_IDS — the built-in texture ids: circle-soft, circle-hard,
square, spark, smoke.
class Effect {
constructor(doc: ParticleDoc, opts?: { seed?: number; x?: number; y?: number }); // seed overrides doc.seed; x/y = initial emitter position
step(dt: number): void; // advance by dt seconds
reset(seed?: number): void; // rewind to t=0 (re-prewarms if configured); keeps the emitter position
setEmitterPosition(x: number, y: number): void; // emitter position at the END of the next step (drives world-space trails)
teleportEmitter(x: number, y: number): void; // jump with no velocity/interpolation (respawns, screen wraps)
setAttractor(x: number, y: number, strength: number, radius: number): void; // host-driven attractor (schemaVersion 4); radius <= 0 clears
clearAttractor(): void; // remove the host attractor
setParam(name: string, value: number): void; // drive an exposed SCALAR parameter (schemaVersion 6); clamps to [min,max]; unknown name / non-finite value = no-op
getParam(name: string): number; // current scalar param value (authored default until first set); unknown / color-kind name ⇒ NaN
setColorParam(name: string, r: number, g: number, b: number, a: number): void; // drive an exposed COLOR parameter (schemaVersion 8); clamps each channel to [0,1]; unknown / scalar-kind name or any non-finite channel = no-op
getColorParam(name: string): RGBAColor | null; // current color param RGBA (a copy; authored default until first set); unknown / scalar-kind name ⇒ null
timeScale: number; // host playback rate; 1 = real time, 0 = frozen (hit-stop), <1 slow-mo; non-finite/≤0 ⇒ 0
onDone: (() => void) | null; // fired once when a non-looping effect finishes (isDone); re-armed by reset()
readonly emitterX: number;
readonly emitterY: number;
readonly time: number; // effect-local seconds
readonly seed: number;
readonly isDone: boolean; // non-looping effect finished and no particles remain
readonly particleCount: number;
readonly layers: readonly LayerSim[]; // per-layer: count, capped, typed-array state
}Each layer has a space: local particles ride the emitter (weld to the
source — muzzle flash, aura); world particles spawn at the emitter's
current position and then simulate independently, so a moving emitter leaves
them behind — a trail (flamethrower, rocket smoke, comet, a flaming
projectile).
Host contract: place view.container at the effect's world origin once,
then drive motion through the emitter — never by moving the container (that drags
world-space particles too). A projectile with a trail:
const fx = new Effect(fireballDoc, { seed });
const view = new PixiParticleRenderer(fx);
view.container.position.set(0, 0); // fixed; the emitter moves, not this
fx.teleportEmitter(startX, startY); // launch point, no start smear
app.stage.addChild(view.container);
app.ticker.add((t) => {
x += speed * (t.deltaMS / 1000);
fx.setEmitterPosition(x, y); // advance the head; world layers shed a trail
fx.step(t.deltaMS / 1000);
view.sync();
});inheritVelocity (per layer, [-2,2]) adds a fraction of the emitter's velocity
to each spawned particle; emission.rateOverDistance spawns particles per pixel
traveled (uniform trail density at any speed). Both are world-space only.
Determinism extends to identical (document, seed, sequence of (dt, emitter position)).
Each LayerSim exposes read-only per-particle typed arrays (x, y, velX,
velY, age, lifetime, rotation, …) plus count and capped. Use
computeRenderState(layerSim, buffers) to evaluate size / color / flipbook-frame
into your own buffers if you're writing a custom renderer — keeping all renderers
identical to the preview.
schemaVersion 3 adds per-layer feature modules, each null = off: noise
(curl turbulence), bySpeed (speed-driven size/color/rotation remaps),
startColor (per-particle spawn tint — two-gradient lerp or a ≤16-color
palette), randomFlip, render (velocity alignment + speed stretch),
collision (floor/rect planes), subEmitters (depth-1 refs to sibling
layers), and trail (per-particle ribbons; v9 adds a connect mode).
overLifetime.velocity gains
additive x/y/orbital/radial tracks; circles gain innerRadius/arc/
arcMode/arcSpeed (donut + arc sweeps; cones get the arc modes too); bursts
gain cycles/interval/probability. The layer cap is 8 (was 4). Exported
types: NoiseConfig, BySpeedConfig, StartColor, RandomFlip,
RenderConfig, CollisionConfig, SubEmitterRef, TrailConfig, ArcMode,
SubTrigger, RGBAColor. v1/v2 documents migrate forward losslessly and
behave bit-identically. Feature behaviors land milestone-by-milestone
(TIER1_PLAN); a document using a not-yet-implemented module validates with an
"unimplemented" warning and the field stays inert. The machine-readable
reference for every field is the JSON Schema shipped with this package
(@particlr/runtime/particle.schema.json).
schemaVersion 4 adds a per-layer attractor (point attractor / vortex; null =
off) and a per-layer attractorInfluence ([-2, 2], 0 = off). The document
attractor: { x, y, strength, tangential, radius, falloff, killRadius } applies
a radial (strength) and tangential (tangential, orbiting) acceleration in
px/s² over particle ageNorm to particles within radius, in the layer's sim
frame (local ⇒ the point rides the emitter). Positive tangential orbits
clockwise on screen. killRadius (0 = off) consumes particles that reach it —
firing death-trigger sub-emitters. The force is applied to stored velocity
(like gravity, so it composes with drag/collision/bySpeed), after the gravity
add and before drag. strength/tangential are constant/curve only (zero PRNG
draws). Exported types: AttractorConfig, AttractorFalloff,
ATTRACTOR_FALLOFFS, DissolveConfig.
Host attractor hook. effect.setAttractor(x, y, strength, radius) drives a
transient attractor from game code; effect.clearAttractor() removes it (a
non-positive radius clears too). Coordinates are parent-frame — the same
frame as setEmitterPosition — and are converted per layer (world layers use
them as-is; local layers subtract the step-end emitter position). The host force
uses a fixed smooth falloff, is radial only (no tangential, no kill), and
is scaled per layer by attractorInfluence — so it is inert on any layer with
influence 0 (the migration default, hence a no-op on every v1–v3 document). The
last call before a step() wins, and the value persists across step() and
reset() until cleared.
const fx = new Effect(doc, { seed });
fx.setAttractor(pointerX, pointerY, 800, 240); // suck particles toward the cursor
// …later…
fx.clearAttractor(); // release themTwo host-only handles on Effect — no document surface, no schema change.
effect.timeScale multiplies the dt you pass to step(). It is applied as
the very first thing step() does, so it composes with the existing guards:
fx.timeScale = 0.25; // slow-motion
fx.timeScale = 3; // fast-forward (each step still clamps to MAX_DT, so a
// fast-forward can never explode emitters)
fx.timeScale = 0; // hit-stop: freeze the effect in place. `timeScale = 0` is
// exactly `step(0)` every frame — the clock and every
// particle hold, and a pending setEmitterPosition target
// stays queued for the next un-paused step.The setter normalizes: any non-finite, negative, or zero value stores 0
(paused) — it never throws. timeScale is sugar over the dt you already
control: timeScale = s; step(dt) is bitwise-identical to
timeScale = 1; step(dt·s). One consequence worth knowing: under slow-motion the
emitter still traverses its full pending segment within the (smaller) scaled step,
so its implied velocity (target − start) / scaledDt rises — a host that
moves the emitter in real time correctly reads as fast in particle-time
(inheritVelocity, world trails). That is authentic game slow-mo, not a bug.
timeScale persists across step() and reset() until reassigned.
effect.onDone is a single completion callback (assign a function or null;
a host wanting fan-out wraps it). It fires synchronously at the end of the
step() on which the effect first reads isDone (a non-looping effect past its
duration with zero live particles) — after all state is committed, so inside the
callback isDone === true and particleCount === 0. It fires at most once
until reset(), which re-arms it (the onDone property itself survives reset(),
like timeScale). Attaching a callback after the effect has already finished
still fires it on the next step() (latch-on-fire, not on transition — friendlier
to hosts that attach late). A paused step (dt ≤ 0, including timeScale = 0)
runs no completion check. Looping effects never fire (isDone is always
false), and prewarm never fires. Calling ordinary API from inside the callback —
including reset() — is legal and safe; the callback runs after the step body, so
it can never affect the state that step produced.
fx.onDone = () => {
view.destroy(); // tear down the renderer
removeFromActiveList(fx);
};
app.ticker.add((t) => { fx.step(t.deltaMS / 1000); fx.view.sync(); });One boom.prt, N weapons. A document may declare named parameters, and
well-known knobs may bind to one. The host drives them per instance — no
document mutation — so a pistol muzzle flash and a rocket blast are the same
.prt at intensity = 0.3 and intensity = 1.0. Parameters come in two
kinds, each with its own typed accessor pair (mirroring VFX Graph's
SetFloat/SetVector4 split):
- scalar (
{ kind: "scalar", name, default, min, max }) — driven bysetParam(name, value)/getParam(name); multiplies one of seven scalar knobs. - color (
{ kind: "color", name, default: RGBAColor }, schemaVersion 8) — driven bysetColorParam(name, r, g, b, a)/getColorParam(name); drives the layer-level tint knob (layer.tintParam). Channels are inherently[0,1]-clamped, so a color param has nomin/max. One spell document, N element colors: a single color param bound totintParamon several layers recolors the whole effect (fire → frost → poison) from onesetColorParam.
// pistol / rifle / rocket from one document
const fx = new Effect(boomDoc, { seed });
fx.setParam("intensity", tier); // 0.3 … 1.0
fx.getParam("intensity"); // read back the current (clamped) value- Author surface.
doc.params: { name, default, min, max }[]. Each bindable knob carries an optional sibling reference field (…Param: string | null);null/absent = unbound. The multiply is multiply-only and applies to the knob's evaluated value — never its stored track keys — so a bound knob at param value1is an IEEE-exact no-op (aparams-carrying document renders byte-identically to one withparams: []). The authoring identity is the value1("default 1 = as authored"); the editor seeds a new param as{ default: 1, min: 0, max: 2 }. - Setter behavior.
setParamclampsvalueinto the param's authored[min, max]; a non-finitevalueis ignored (no-op) and an unknownnameis a silent no-op — house tolerance, no throws (cf.timeScalenormalizes,setAttractorclears).getParamreturns the authoreddefaultuntil the firstsetParam, andNaNfor an unknown name.setColorParamis the color sibling: it clamps each channel to[0,1], and any non-finite channel rejects the whole call (no partial write); an unknown name OR a scalar-kind name is a silent no-op.getColorParamreturns a copy of the current RGBA (the authoreddefaultuntil the firstsetColorParam), ornullfor an unknown / scalar-kind name. The scalar and color namespaces are independent — the typed accessors never cross (agetParamon a color name isNaN, agetColorParamon a scalar name isnull). For sim-consumed knobs (rate/speed/life/gravity) the last call before astep()wins; the render-path knobs (size/opacity/tint) are frame-live — a set shows in the very next render even while paused (timeScale = 0hit-stop, a paused preview), no step needed. Param values (scalar and color) persist acrossreset()(liketimeScale) — so "scrub withintensity = 0.7" replays exactly.
The bindable knobs, and whether a mid-flight change is live (already-alive particles respond next step/render) or future-spawn (baked at spawn):
| Binding field | Knob | Kind | Mid-flight |
|---|---|---|---|
emission.rateOverTimeParam |
continuous emission rate | scalar | emission timing (future) |
emission.rateOverDistanceParam |
rate-over-distance (world trails) | scalar | emission timing (future) |
initial.speedParam |
launch speed | scalar | future spawns only |
initial.lifeParam |
particle lifetime | scalar | future spawns only |
initial.sizeParam |
particle size (render multiply) | scalar | live |
overLifetime.velocity.gravityParam |
gravity vector | scalar | live |
opacityParam (layer-level) |
particle alpha | scalar | live |
tintParam (layer-level) |
RGBA tint on the finished color chain | color | live |
speed/life are baked into pool state at spawn, so changing them never rescales
particles already on screen; size/gravity/opacity/tint are re-evaluated
every step/frame, so they retune the whole live population for free. The tint
multiplies each particle's finished RGBA (gradient × startColor × bySpeed × tint
× opacity — tint before opacity; both commute) with an implicit base of white
{1,1,1,1}, so an unbound tint is the untouched render path and a tint at white is
byte-identical. Params join the determinism tuple (below): the vector of
current scalar and color param values in force at each step() is a per-step host
input, exactly like dt and timeScale.
schemaVersion 4 adds a per-layer dissolve (null = off):
{ frequency, scroll, edgeWidth, edgeColor }. It is a renderer-only effect —
the sim is untouched, no PRNG draws, no pool columns. The particle's final render
alpha is the erosion progress: whatever modules drive per-particle alpha (the
color gradient, bySpeed, startColor) drive the dissolve, so a fading puff
erodes through an internal procedural noise tile instead of fading uniformly.
frequency ∈ (0, 64] is the noise repeat across the sprite; scroll is UV/s over
the effect clock (uTime = effect.time, so the burn is exact under scrub and
golden replay); edgeWidth ∈ [0, 1] is the soft erosion band; edgeColor
(null = off) is a hot-rim RGBA tint that glows at the erosion edge. It keeps
one draw call per layer (no render targets, no extra passes) via a forked
ParticleContainer shader. Exported type: DissolveConfig.
Renderer parity — WebGL only. The dissolve fork ships both a GLSL and a WGSL
source, but only the WebGL (GLSL) path is verified: the golden-frame suite
runs SwiftShader WebGL and there is no WebGPU golden lane yet, so L4
preview/runtime parity is attested on WebGL only; the WGSL path ships
unverified. Pin preference: "webgl" in your host app.init if you rely on
byte-exact parity (the editor preview, the golden harness, and the live
sample host all do). Dissolve does not erode a trail ribbon
(E25) — a trail on the same layer renders un-eroded through its separate mesh
shader.
schemaVersion 5 adds four companion features. Every one reuses an
already-drawn per-particle uniform, so the batch adds zero new PRNG draws and
zero pool columns — a v4 document migrates forward bit-identically
(limitVelocity defaults null; a flipbook gains randomStartFrame: false,
frameOverLife: null; the new track/startColor modes are opt-in enum values).
limitVelocity: ScalarTrack | null(per layer; constant/curve only — no range/randomBetweenCurves, zero draws). A speed cap over particle ageNorm, applied to stored velocity right after drag and before the position write, so it persists and composes like drag:speed = √(velX²+velY²); whenspeed > capthe velocity is scaled down tocap.cap = 0freezes particles in place (E27).randomBetweenCurvesScalarTrackmode —{ mode, a: CurveKey[], b: CurveKey[] }. Each particle draws a stable blend factorrfor life and evaluateslerp(evalCurve(a, t), evalCurve(b, t), r), so the population reads as a mix of two shapes. Valid only on the eight per-particle over-lifetime tracks that own a reserved spawn uniform —overLifetime.size,overLifetime.rotation, andoverLifetime.velocity.{drag, speedMultiplier, x, y, orbital, radial}— consuming the SAME uniformrangedoes (hence no new draw). The validator rejects it onemission.rateOverTimeand every constant/curve-only track.hueJitterstartColormode —{ mode, degrees }(degrees ∈ [0, 180]), mutually exclusive with the two-gradient/palettestartColor modes. At spawn it reuses the existing startColor uniform (draw 19) to store a per-particle hue offset∈ [−degrees, +degrees]; at render it hue-rotates the over-lifetime gradient colour per particle (alpha untouched) instead of multiplying a tint — so a warm base reads as a natural spread of hues.- Flipbook
randomStartFrame: boolean+frameOverLife: ScalarTrack | null(both render-only, deterministic, NOT folded into the statehash). Frame-index precedence (E30):frameOverLife— a deterministic 0..1 position across the sheet evaluated over ageNorm — overrides the mode entirely; elsemode: "random"keeps its stable per-particle frame (randomStartFrameignored); elseloop/oncewithrandomStartFrameadds a per-particle start offset (reusing the draw-13 frame uniform), then wraps (loop) or clamps (once). WithrandomStartFrame: falseandframeOverLife: nullthe frame is exactly⌊age·fps⌋, byte-identical to v4.
schemaVersion 9 adds trail.mode: "perParticle" | "connect" (migration injects
"perParticle", so every v8 document behaves bit-identically).
"perParticle"— the pre-v9 behavior: each particle carries its own polyline of recent positions."connect"— the layer emits ONE ribbon threaded through all of its currently-live particles' current positions (energy beams, lightning, chains — a Shuriken connected ribbon / Effekseer track). No position history is kept, so the ring buffer is not allocated andmaxPoints/minVertexDistanceare ignored (E36). Vertices are ordered oldest → newest by a stable per-particle spawn ordinal, so a particle dying mid-ribbon (swap-removed from the pool) never reorders the survivors — the order is deterministic and independent of pool compaction.width/colorsample over ribbontwitht = 0at the newest particle (head), matching per-particle trails;colornull ⇒ each vertex takes its own particle's current render RGBA (a non-null gradient scales its alpha by that vertex's particle alpha). Fewer than 2 live particles ⇒ no ribbon (empty geometry, never a degenerate quad). The stable ordinal is assigned from the per-layer spawn counter, so connect mode adds zero PRNG draws; it reuses the same mesh/blend render path as per-particle trails.
Sub-emitter property inheritance. Each SubEmitterRef gains three booleans
beside inheritVelocity — inheritColor, inheritSize, inheritRotation
(migration injects false). At the trigger event the parent particle's state is
captured and applied to each child at spawn; the child's own PRNG stream is never
touched (inheritance modifies drawn RESULTS, zero new draws).
| flag | captured from the parent (at the event moment) | applied to the child |
|---|---|---|
inheritColor |
the parent's sim-side RGBA — the over-life gradient at ageNorm × the startColor tint including any hueJitter hue rotation, excluding bySpeed and host-param tints (those are render-only) |
a per-particle multiply on the child's finished color chain (a dedicated inherit-RGBA column, allocated only on a layer that is the target of ≥1 inheritColor ref) — after startColor, before bySpeed |
inheritSize |
the dimensionless over-life size FACTOR evalScalarTrack(overLifetime.size, ageNorm, rand0) (1 when the track is null) — not the px size (px × px is nonsense; the factor gives "a shrinking parent spawns smaller children") |
the child's drawn size is multiplied by that factor (baked into sizeInit) |
inheritRotation |
the parent's current rotation in degrees | added to the child's drawn rotation |
Capture is per-PARENT-layer gated: a parent whose refs all leave every flag
false records the flat pre-v9 event and does no capture work, so a document
that opts out is bit-identical to schemaVersion 8. The full color chain is
gradient × startColor × inherit × bySpeed × tint × opacity.
| # | Case | Behavior |
|---|---|---|
| E1 | dt larger than 1/20 s (tab unhide) |
clamped to 1/20 s; never sub-steps |
| E2 | dt <= 0 |
no-op |
| E5 | prewarm | simulates one full duration of continuous emission (bursts suppressed) before the first visible frame |
| E6 | looping: false ends |
emitters stop; live particles finish; isDone becomes true at 0 particles |
| E7 | pool full (maxParticles) |
new spawns dropped silently; layer.capped flags it |
| E8 | seeking a time | reset(seed) then step(1/60) to the target — exact, thanks to determinism |
| E9 | serializing while playing | serializeParticle uses the authored document; playback state never serializes |
| E15 | teleportEmitter (respawn/wrap) |
jump with no velocity and no spawn interpolation across the gap; resets the distance accumulator |
| E16 | prewarm on a world-space layer | prewarm runs at the initial emitter position with zero velocity; particles pile there |
| E27 | limitVelocity cap = 0 |
clamps stored velocity to zero — particles settle in place (valid, not an error) |
| E30 | flipbook frame precedence | frameOverLife (deterministic) > mode:"random" > loop/once + randomStartFrame |
class PixiParticleRenderer {
constructor(effect: Effect, opts?: { renderer?: unknown });
readonly container: Container; // one ParticleContainer per layer
readonly warnings: string[]; // e.g. a missing user texture fell back to a built-in
sync(): void; // call after effect.step(dt)
destroy(): void;
}Built-in textures are generated procedurally (pure math, no asset files, no
canvas) so output is identical across browsers and GPUs. Blend modes map
directly to Pixi's (normal/add/multiply/screen).
Embedded user textures ("user:<name>" refs backed by a data URL in
doc.textures) decode asynchronously: the layer renders the soft-circle
built-in until the image is ready, then swaps in. Decoded user textures are
cached by data URL and shared across renderer instances for the lifetime of the
page — the runtime does not evict them (per-texture refcounting is a v1.5
concern; a document embedding many large textures is out of scope for v1).
Given identical (document, seed, sequence of (dt, emitter-position, host-attractor state, timeScale, param-values) tuples), output is bit-identical —
the emitter position (setEmitterPosition), the host attractor (setAttractor
parameters), timeScale, and the exposed-parameter values (scalar setParam,
schemaVersion 6, and color setColorParam, schemaVersion 8) are per-step host
inputs exactly like dt (schemaVersion 4 + host-API amendments). The runtime
never reads wall-clock time, Math.random, or any global — the only randomness
is a seeded mulberry32 PRNG, one stream per layer. This is what makes seek/scrub
exact and enables golden-frame testing.
.prt is a small, versioned, declarative JSON document. Its machine-readable
reference is the JSON Schema shipped with this package —
particle.schema.json
(import schema from "@particlr/runtime/particle.schema.json").
Because the format is small, documented, and agent-readable, a coding agent can author or tweak effects directly — "make the explosion 20% punchier and shift it orange" is a JSON edit against a published schema, no editor required.
MIT. See LICENSE.