Game of Life on signals
A computed does not have to describe a value derived from state. Its committed value can be the state. Here the whole world is one computed, and prev is the previous generation.
- generation
- 0
- world runs · gens
- –
- projections run
- –
- cells repainted
- –
- ms
- –
The whole game
import { computed, effect, get, signal, update } from "reflex-quant";
const generation = signal(0);
const world = computed((prev) => {
const target = get(generation);
let { generation: current, cells } = prev;
while (current < target) { // coalesced requests still mean every generation
cells = evolve(cells);
current++;
}
return current === prev.generation ? prev : { generation: current, cells };
}, { generation: 0, cells: seed });
const step = () => update(generation, (n) => n + 1);There is no writable store for the board. step() only says that one more generation must exist; how to get there from the last committed world is the computed's job. That is S(t+1) = F(S(t)) written directly.
The while loop matters. prev advances per execution, not per write: ten updates in one batch (or while nobody reads world) produce one execution. Press +10 in one batch: the counter shows one world run and ten generations. Laziness does not change the model.
Coarse state, fine observation
const cell = grid.map((_, i) => computed(() => get(world).cells[i])); // 0 → 0 is a cutoff
cell.forEach((c, i) => effect(() => paint(i, get(c)))); // runs on a flip onlyEvery projection re-runs when the world changes, but one that returns the same 0 or 1 is a cutoff: its effect does not run. So storage is one value and painting is per cell. The counters show the cost honestly: all 5,376 projections run each generation, only the flipped cells repaint.
Why not write cells from inside a computed? Then the first updated cell would be visible to the second in the same generation, and Conway's synchronous automaton would become a sequential sweep. Quant permits such writes; Life does not want them.
When the frontier is small
The world model recomputes all cells every generation. If only a few cells change per step, per-cell signals keep work local, and prev still removes the self-dependency:
const cell = grid.map((v) => signal(v));
const next = cell.map((_, i) => computed((self) => { // prev = this cell's state
let alive = 0;
for (const j of neighbours(i)) alive += get(cell[j]); // 8 edges, no self edge
return alive === 3 || (alive === 2 && self) ? 1 : 0;
}, grid[i]));
function step() {
const flips = cell.filter((c, i) => get(next[i]) !== get(c));
batch(() => flips.forEach((c) => update(c, (x) => x ^ 1)));
}This works because the rule is idempotent for unchanged neighbours, so a next that did not re-run is still right. It holds only while step() is the sole writer: a cell set from outside (drawing, a reset) does not invalidate its own next, and the next step would undo the write. The demo above uses the world model for that reason.
Next: Fireflies, where a computed also writes, and one effect becomes a fixpoint solver.