Skip to content

Showcase: Physarum network

A Physarum (slime-mold) transport network: tens of thousands of agents deposit a pheromone trail, sense their own trail ahead, and steer toward it. Positive feedback condenses diffuse pheromone into sharp branching veins; decay prunes the unused ones; the network continuously rewires itself. It is among the gentlest of the showcases in terms of engine footprint, so it’s the best place to learn the shared pattern.

Narrowly: GameLoop + CoroutineManager (@flare-engine/core), SeededRNG + clamp/lerp (@flare-engine/math), an optional spark ParticlePool (@flare-engine/particles), and the bloom/aberration finish (@flare-engine/postfx). The entire simulation — the agents and the pheromone field — is your own framework-free code. No ECS, no physics package. That’s the point: the engine makes a heavy custom sim run deterministically and glow; it doesn’t run it.

With tens of thousands of agents, per-entity objects would thrash the garbage collector. Store them as a structure of arrays — parallel Float32Arrays — instead:

const px = new Float32Array(count); // x position
const py = new Float32Array(count); // y position
const heading = new Float32Array(count); // direction, radians

Scatter them reproducibly with the engine’s SeededRNG so a given seed always yields the same network — invaluable when you’re tuning:

import { SeededRNG } from "@flare-engine/math";
const rng = new SeededRNG(seed);
for (let i = 0; i < count; i++) {
px[i] = rng.float(0, width);
py[i] = rng.float(0, height);
heading[i] = rng.float(0, Math.PI * 2);
}

physarum/field.ts is a TrailField — a coarse scalar grid with no engine or CanvasKit imports, unit-tested with bun test. It exposes a handful of allocation-free operations:

  • senseAt(x, y) — bilinear sample of the field (what an agent “smells”).
  • deposit(x, y, amount) — additive drop where an agent stands.
  • step() — one diffuse + decay tick (below).
  • toRGBA(...) — pack the whole grid into an RGBA byte buffer for the renderer.

Each fixed step, every agent reads three sensors — front, front-left, front-right — turns toward the brightest, adds a little chaos, then steps forward (with toroidal wrap) and deposits:

function stepAgents(field: TrailField, dt: number): void {
for (let i = 0; i < count; i++) {
const a = heading[i];
const f = field.senseAt(px[i] + Math.cos(a) * SENSE, py[i] + Math.sin(a) * SENSE);
const l = field.senseAt(px[i] + Math.cos(a - SENSE_ANGLE) * SENSE, /* ... */);
const r = field.senseAt(px[i] + Math.cos(a + SENSE_ANGLE) * SENSE, /* ... */);
heading[i] = decideTurn(a, f, l, r) + (rng.float(-1, 1) * chaos);
px[i] = wrap(px[i] + Math.cos(heading[i]) * SPEED, width);
py[i] = wrap(py[i] + Math.sin(heading[i]) * SPEED, height);
field.deposit(px[i], py[i], DEPOSIT);
}
}

decideTurn and senseAt are pure functions with their own tests — the rule’s correctness is verified without ever touching a canvas.

The network’s life is in the field’s step(): a separable 3×3 box blur (a horizontal pass then a vertical pass into a ping-pong buffer — far cheaper than a 2D kernel) followed by a multiplicative decay and a clamp. Reusing two preallocated buffers means zero allocation per frame:

step(): void {
boxBlurH(this.buf, this.tmp, this.w, this.h); // horizontal pass → tmp
boxBlurV(this.tmp, this.buf, this.w, this.h); // vertical pass → buf
for (let i = 0; i < this.buf.length; i++) {
this.buf[i] = clamp(this.buf[i] * this.decay, 0, this.max); // prune + stabilize
}
}

Decay is what makes it loop forever with no reset: unused veins fade, active ones are continuously re-fed, so the network is always rewiring.

Run the agents and the field on the fixed step so behavior is frame-rate-independent; let the CoroutineManager choreography ride the variable step:

loop.onFixedUpdate.add((dt) => { stepAgents(field, dt); field.step(); });
loop.onUpdate.add((dt) => coro.update(dt));

A few nutrient sources walk a seed → branch → migrate → reknit cycle and emit into the field, so the web grows veins toward food (the famous Tokyo-rail result). Each wait is gated on the network’s live density, never a clock:

function* nutrients() {
placeSeed();
yield* waitUntil(() => density() > seedThreshold); // wait for a core to form
branch();
yield* waitUntil(() => density() > branchThreshold);
migrate(); // move the food; veins chase it
yield* waitUntil(() => reknit());
}

The key rendering move: don’t draw cells. Pack the entire field into one RGBA buffer with a palette-binned ramp, build one GPU texture with kit.MakeImage, and blit it upscaled with a single drawImageRectOptions call. Nutrient nodes, a sparse agent shimmer, and ignition sparks ride on top; then the shared surface composites bloom + aberration:

draw(sim: PhysarumSim): void {
const sc = this.surface.sceneCanvas();
this.surface.fadeTrail(BG, 0.2);
const bytes = sim.field.toRGBA(this.scratch); // grid → RGBA, palette ramp
const img = this.kit.MakeImage(this.imageInfo, bytes, this.rowBytes);
sc.drawImageRectOptions(img, srcRect, dstRectUpscaled, /* nearest */, paintPlus);
img.delete();
drawNutrients(sc); drawAgentShimmer(sc);
this.surface.composite(sim.glow()); // engine bloom + aberration → present
}
  1. Agents as parallel Float32Arrays, scattered with SeededRNG.
  2. A pure, bun-tested TrailField: sense, deposit, separable blur + decay, toRGBA.
  3. The Jones sense-rotate-move rule on loop.onFixedUpdate.
  4. Density-gated nutrient choreography on a CoroutineManager.
  5. Render the field as one MakeImage blit; finish with the engine’s post-FX surface.

The engine carried the loop, the RNG, an optional spark pool, and the glow. Everything that makes it Physarum is the pure field + agent code — yours to keep and to unit-test.