Showcase: Nuclear reactor
An emergent chain reaction that is the simulation: neutron agents fly across the canvas, strike fissile atoms, and on a fission roll spawn more neutrons; control rods, leakage, and finite lifetime remove them. The visible neutron population is the reactor “power.” It is regulated by real reactor physics — not a hidden multiplier. This is the only showcase that uses the engine’s ECS and animation packages, so it’s the one that teaches them.
What the engine provides here
Section titled “What the engine provides here”The richest footprint of all five: GameLoop + CoroutineManager (@flare-engine/core),
World + defineComponent (@flare-engine/ecs), tween + updateTween
(@flare-engine/animation), ParticlePool (@flare-engine/particles), clamp/lerp
(@flare-engine/math), and the post-FX finish (@flare-engine/postfx).
The reactor physics itself is yours. reactor/physics.ts is framework-free, zero-import,
bun-tested TypeScript — six-group delayed-neutron kinetics, a Doppler feedback term, control-rod
worth curves. The engine does not simulate reactors; it gives you the loop, the entities, the
tweens, the pool, and the glow.
Modeling agents in the ECS
Section titled “Modeling agents in the ECS”Atoms and neutrons are entities in an @flare-engine/ecs World. Define small components and
spawn entities with them:
import { defineComponent, Position, Velocity, World } from "@flare-engine/ecs";
const Neutron = defineComponent("Neutron", () => ({ life: 0 }));const Atom = defineComponent("Atom", () => ({ fissile: true }));
const world = new World();
function spawnNeutron(x: number, y: number, vx: number, vy: number): void { const e = world.create(); world.add(e, Position, { x, y }); world.add(e, Velocity, { x: vx, y: vy }); world.add(e, Neutron, { life: 0 });}The loop: transport vs. choreography
Section titled “The loop: transport vs. choreography”Neutron transport — move, leak at the boundary, age out, and roll fission on contact — runs on the deterministic fixed step. Display smoothing and the operating-cycle choreography ride the variable step:
const loop = new GameLoop({ fixedDt: 1 / 60, maxDt: 0.1 });
loop.onFixedUpdate.add((dt) => transport(world, dt)); // emergent chain reactionloop.onUpdate.add((dt) => { coro.update(dt); updateTween(rodTweens, dt); smooth(dt); });transport is the whole reaction: for each neutron, integrate position, remove it on leakage /
absorption / lifetime, and on a fission hit spawn ν daughter neutrons (mean 2.43) and a spark
burst. Nothing is ever cleared; the population rises and falls emergently.
Grounding it in real physics — your code, not the engine’s
Section titled “Grounding it in real physics — your code, not the engine’s”The number that decides how many neutrons survive each generation comes from reactor/physics.ts,
which models the genuine article:
- Six delayed-neutron precursor groups (Keepin U-235 λᵢ/βᵢ) emit into a reservoir — the slow feedback that makes a reactor controllable rather than a bomb.
- An inflated “visual beta” (~0.12 vs. the real 0.0065) widens the controllable prompt-critical margin so the animation reads on a human timescale — an honest piece of cartooning, called out as such.
- A lumped fuel-temperature ODE drives a Doppler self-limit: as it heats, reactivity drops, so the reaction self-regulates.
- S-curve rod worth shapes how much each control rod absorbs as it plunges.
import { dopplerFactor, routeDaughter, rodIntegralWorth } from "../reactor/physics";// pure, tested helpers — no engine, no DOMBecause it’s pure, it has a physics.test.ts next to it: the kinetics are verified with
bun test, independent of the renderer.
Timer-free choreography
Section titled “Timer-free choreography”A CoroutineManager runs the operating cycle — startup → supercritical → damping → peak → scram → loop — with no wall-clock timers. The dramatic beat is six control rods plunging in one by one in
random order, each plunge gated on the live neutron population:
function* operatingCycle() { yield* waitUntil(() => allRodsOut()); yield* waitUntil(() => population() >= growthTrigger()); // let it go supercritical for (const rod of shuffledRods()) { plungeRod(rod); // start a tween (below) yield* waitUntilOr(() => rodSeated(rod), () => population() < dampFloor()); } yield* waitUntil(() => population() <= crashFloor()); // scram, then loop}Animating control rods with tweens
Section titled “Animating control rods with tweens”Each rod’s plunge and withdrawal is an @flare-engine/animation tween, advanced on the variable
step with updateTween — independent, eased motion without hand-rolled interpolation:
import { tween, updateTween } from "@flare-engine/animation";
const rodTween = tween({ from: rod.y, to: seatedY, duration: 0.8, ease: "cubicInOut" });// each frame: updateTween(rodTween, dt); rod.y = rodTween.value;Fission sparks
Section titled “Fission sparks”Each fission fires a short burst from an @flare-engine/particles ParticlePool — acquired,
updated, and read back for color/size/alpha, all pooled so there’s no per-spark allocation:
import { ParticlePool } from "@flare-engine/particles";
const sparks = new ParticlePool(maxSparks);function burst(x: number, y: number): void { for (let i = 0; i < 8; i++) sparks.acquire({ x, y, vx: rng.float(-1,1), vy: rng.float(-1,1) });}Drawing: raw CanvasKit + the rod aberration trick
Section titled “Drawing: raw CanvasKit + the rod aberration trick”The scene is immediate-mode raw CanvasKit onto the shared offscreen surface with additive Plus
blending. Unlike Physarum and Spacetime, the reactor hard-clears every frame —
fadeTrail(BG, 1), not a translucent fade — so the entities stay crisp and trail-free rather than
smearing into streaks: atoms, neutrons, and sparks are circles; rods are light-lines. The signature
touch is per-rod chromatic aberration: every neutron hit deposits a Gaussian bump along the
rod’s length that decays over time, and the renderer reads that profile to split and jitter the
rod’s light into cyan/magenta fringes. Then the engine’s post-FX surface adds bloom and aberration
globally, driven by the reactor’s glow()/warmth():
this.surface.fadeTrail(BG, 1); // hard clear — crisp entities, no motion trailsdrawAtoms(sc); drawNeutrons(sc); drawSparks(sc); drawAberratingRods(sc);this.surface.composite(sim.glow()); // engine bloom + aberration → presentParallax depth — the “Five Layers”
Section titled “Parallax depth — the “Five Layers””The scene isn’t flat. Every atom, neutron, spark, and rod is dealt to one of up to five receding
depth planes (layerCount, default 5 — the “Five Layers”), and the renderer treats depth as pure
aerial perspective. As a layer recedes, its entities are drawn smaller (layerScaleFar),
dimmer (layerDimFar), pulled inward toward the frame centre / vanishing point (layerConverge),
desaturated toward grey (layerGrey), and softened with a cached per-layer Gaussian
depth-of-field blur (layerBlur, sharp in front, out-of-focus at the back):
const dp = sim.depthParams(); // { count, scaleFar, dimFar, converge, grey, blur }sim.forEachAtom((x, y, r, fissile, layer) => { const t = layer / (dp.count - 1); // 0 = front plane … 1 = farthest drawAtom(project(x, y, t), r * scale(t), dim(t), grey(t), blurFor(layer));});The key discipline: depth is presentation only. The chain reaction runs on true positions and
full-height rods; layers are applied at draw time (rods stay anchored to the top edge but render
shorter, thinner, and converged when farther), so physics stays global and frame-rate-independent
while the picture gains real 3D-ish depth. All six knobs are live-tunable in the dev panel; set
layerCount to 1 for the old flat look.
Wiring the dev panel
Section titled “Wiring the dev panel”Because the instance optionally implements params()/getParams()/setParam()/stats(), the
shared dev panel feature-detects it and gives you live tuning. Each ParamDescriptor declares
whether it applies "live" (mutated in place) or "restart" (needs a sim re-create); stats()
returns labeled StatReadouts (population, fuel temperature, reactivity) with thresholds the panel
renders in a warning state when exceeded.
- Atoms and neutrons as ECS entities; transport on
loop.onFixedUpdate. - Real six-group kinetics + Doppler feedback in a pure, bun-tested
physics.ts. - The operating cycle as a population-gated
CoroutineManager. - Control-rod plunges as
@flare-engine/animationtweens; fission sparks from aParticlePool. - Raw-CanvasKit scene, hard-cleared for crisp entities, with the rod aberration trick and five-layer parallax depth (scale / dim / converge / grey / blur); engine bloom on top.