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.

draw with the pointer
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 only

Every 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.