Skip to content

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.

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.

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 });
}

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 reaction
loop.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 DOM

Because it’s pure, it has a physics.test.ts next to it: the kinetics are verified with bun test, independent of the renderer.

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
}

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;

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 framefadeTrail(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 trails
drawAtoms(sc); drawNeutrons(sc); drawSparks(sc); drawAberratingRods(sc);
this.surface.composite(sim.glow()); // engine bloom + aberration → present

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.

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.

  1. Atoms and neutrons as ECS entities; transport on loop.onFixedUpdate.
  2. Real six-group kinetics + Doppler feedback in a pure, bun-tested physics.ts.
  3. The operating cycle as a population-gated CoroutineManager.
  4. Control-rod plunges as @flare-engine/animation tweens; fission sparks from a ParticlePool.
  5. 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.