Fireflies: a fixpoint without a loop
Each firefly is a computed whose phase lives in prev. A flash writes to its neighbours' inboxes from inside that computation. There is no event queue and no stabilization loop: the runtime's invalidations are the loop.
- tick
- 0
- flashed
- –
- computed runs
- –
- pulses sent
- –
- driver runs
- –
- ms
- –
They start at random phases and flash on their own. A firefly that sees a flash is nudged forward; one pushed over the edge flashes with the one that pushed it and stays in step. Swarms fall into step first, then the whole field: within a few hundred ticks all 600 flash together. On the way there, one step() can carry an avalanche across hundreds of fireflies. Press Step during a wave and read the bars under the counters.
How they fly
The flight follows what is known about Photinus fireflies, simplified:
- Males fly slowly and low over the grass on wandering paths, and stay near where they emerged, so they gather in swarms.
- A flash comes with a swoop up, the "J" stroke of P. pyralis: the light traces a short climb.
- Females mostly perch in the grass and flash from there. About one in five here does not fly.
- Synchronous species such as P. carolinus flash in short bursts and then go dark together. Each burst here is three quick flashes.
The flight is ordinary code, not reactive state. It decides one thing: who is close enough to see whose flash, up to the 8 nearest within reach. When a firefly flashes it writes to whoever is near at that moment. Those are writes, not reads, so the reactive graph stays the same while everything flies: three edges per firefly, whoever its neighbours are.
The whole simulation
import { computed, effect, get, signal, update } from "reflex-quant";
const tick = signal(0);
const pulse = grid.map(() => signal(0)); // one inbox per firefly
const firefly = grid.map((_, i) => computed((prev) => {
const t = get(tick);
let phase = prev.phase + (t - prev.tick) * 0.01; // time advances once per tick
let lit = prev.lit;
const incoming = get(pulse[i]);
if (incoming) {
update(pulse[i], () => 0); // consume the inbox
if (t - lit > 30) phase += incoming; // refractory after a flash
}
if (phase >= 1) { // flash: reset, kick the neighbours
phase = 0;
lit = t;
for (const j of near[i]) update(pulse[j], (x) => x + 0.11); // whoever sees it now
}
return { tick: t, phase, lit };
}, { tick: 0, phase: Math.random(), lit: -Infinity }));
effect(() => { for (const f of firefly) get(f); }); // the solver
const step = () => update(tick, (t) => t + 1);That is all of it. The demo adds one more signal, epoch, so Scramble can restart every firefly.
The model is cyclic. The dependency graph is not.
Physically, A excites B, B excites C, and C can excite A again. In the graph there is no path from A to B at all. A writes into B's inbox, and B depends only on its inbox. The causal chain is long; every dependency chain is one step. The loop of the model runs through time and messages, not through the graph.
One firefly: three edges in, zero out
- Reads are how causes get in.
get()records an edge. A firefly reads the same three sources in the same order on every run, so after its first run each read is a cursor hit: no lookup, no relinking. - Writes are how effects get out.
update()is not a read: it runs untracked, so a firefly that can start an avalanche across the whole field still has three dependencies.
Where the state lives
// Without prev: the oscillator's state needs its own signals
const phase = grid.map(() => signal(Math.random()));
const lit = grid.map(() => signal(-Infinity));
// ...and the computation reads them and writes them back: a feedback loop in the graphFor 600 fireflies that is 1,200 extra signals, two more edges per firefly, and a computation that writes its own sources every tick. With prev, the phase, the last flash and the last tick cost zero graph objects: they are the computed's committed value.
Push and pull, on the shortest possible track
A write marks its direct subscribers DIRTY, and a DIRTY computed is executed without checking its sources. Every firefly subscribes to tick directly, so a tick never builds a chain of "maybe stale" nodes to walk back through. Quant's general machinery is all still there; here every case it meets is the cheapest one.
A dense first pass, then a sparse frontier
The driver reads the fireflies in order. Firefly 400 flashes and writes into the inbox of firefly 120, which the driver has already read. Two rules of the contract decide what happens:
- P5: once a computation has observed a source, a later change to it leaves an execution owed.
- W2: a watcher invalidated during its own run is delivered again.
So the driver comes round again. On that pass only the fireflies with a new pulse are DIRTY; the others are clean and cost a version check each, not a run. The bars under the demo are measured, not drawn. The first bar is the whole field; the next ones are only the fireflies that took a pulse. The driver still walks all 600 of its edges on every pass; what shrinks is the work done at them. Delivery also validates before it runs: a firefly that took a pulse during its refractory time returns prev, a cutoff, and the driver's body is not run at all. That is why a tick where the field is already in step shows thousands of pulses and a single driver run.
Two clocks
One firefly can run several times in one tick. prev.phase + (t - prev.tick) * 0.01 charges time only for ticks it has not seen yet, so domain time is t → t + 1 however many executions happen.
Why it terminates
A flash removes 1 from the flashing firefly and hands out at most 8 × 0.11 = 0.88. Every flash loses at least 0.12 of total phase, so every avalanche ends. If a flash gave out 1 or more, nothing would be lost, and the driver could cycle forever. The runtime finds the fixpoint; the model has to have one. The refractory period is what makes the field synchronize instead of forming drifting patches.
The same program elsewhere
- Preact Signals has ordinary writable effects and
peek(), so a plain sandpile or cellular automaton can be built much the same way. Butcomputed(fn)gets no previous value, so the oscillator's private state has to move out into a writable signal or a closure. README - Solid passes the previous value to memos and effects. But a memo "should be pure (it should not modify other reactive values)", so the flash has to move into an effect; effects run after memos, and "the order of runs among multiple effects is not guaranteed". createMemo, createEffect
- Vue passes the previous value to a computed getter since 3.4, so half the construction is there. But the official model requires getters to be free of side effects and not to mutate other state.
update(pulse[j], …)breaks exactly that, so pulse propagation moves into watchers and the single transition node falls apart. Computed properties - Angular is more direct still: a signal write during a
computedthrowsNG0600(unless it is wrapped inuntracked, which the docs reserve for writes that cannot be moved). Effects may write, but then state transition and propagation again split into different primitives. NG0600
An important caveat: none of this means the program is mathematically impossible in Solid, Vue or Preact. Of course it is possible. JavaScript, after all, is expressive enough to implement another JavaScript inside it, because we have not deserved peace.
The strong claim is a different one: there, this program stops matching the natural structure of the model. It needs at least one of:
external mutable state actions watchers queues two-phase updates manual stabilization scheduler
In Quant the model stays the model.
Simpler first: Game of Life, where the whole world is one computed and prev is the previous generation. Next: Constraints, where knowledge converges instead of events.