reaction-diffusion
The Gray-Scott model: two virtual chemicals react and diffuse across a grid, producing self-organizing spots, stripes, and coral from a few lines of math.
A low feed rate relative to the kill rate leaves the field mostly at rest, punctuated by small, roughly round spots that hold a stable size once they form.
Every cell on the grid holds two concentrations, u and v, standing in for two chemicals. Each step, three things happen everywhere at once: the chemicals diffuse into neighboring cells at their own rates, they react wherever both are present (v is autocatalytic — it consumes u to make more of itself), and a steady feed rate F replenishes u while a kill rate k removes v. That's the entire model — no rule beyond those three terms, applied identically to every cell, wraparound at the edges.
What those two numbers, F and k, do to the outcome is the interesting part: nudging either one moves the system between completely different regimes, from a field that stays mostly still except for scattered spots, to one that never settles into anything fixed at all. The five presets below sit at different points in that space — try dragging the sliders slowly while it runs to watch one regime turn into another, rather than jumping between them.
view source
import { useEffect, useRef, useState } from 'react';
const GRID_W = 160;
const GRID_H = 100;
const Du = 0.16;
const Dv = 0.08;
const DT = 1.0;
const V_SCALE = 0.5;
const BG: [number, number, number] = [14, 17, 19];
const FG: [number, number, number] = [124, 242, 156];
interface Preset {
id: string;
label: string;
F: number;
k: number;
blurb: string;
}
// Every preset here was checked standalone (not trusted from memory) before
// being wired in: run for thousands of steps from a seeded blob, confirmed
// every cell stays finite and in a physically sane range throughout, and
// tracked how much of the grid the pattern actually covers over time. The
// first pass at these parameters all looked reasonable by a one-shot
// variance check, but several of them turned out to collapse into a handful
// of small stationary dots within the first second and then never change
// again -- a real, verified difference between "produces nonzero variance"
// and "does the thing its name says it does." These values were re-picked
// by tracking grid coverage across checkpoints and keeping only the ones
// that kept growing or kept actively rearranging instead of freezing.
const PRESETS: Preset[] = [
{
id: 'spots',
label: 'spots',
F: 0.035,
k: 0.065,
blurb:
'A low feed rate relative to the kill rate leaves the field mostly at rest, punctuated by small, roughly round spots that hold a stable size once they form.',
},
{
id: 'stripes',
label: 'stripes',
F: 0.032,
k: 0.06,
blurb:
'A slightly lower feed and kill rate than spots keeps growth going instead of letting it settle: fronts elongate and push outward until they fill most of the grid with wandering stripes and loops.',
},
{
id: 'coral',
label: 'coral',
F: 0.025,
k: 0.055,
blurb:
'A branching front that keeps advancing into open space, thickening and fusing where two branches meet — it fills the grid without ever quite closing into loops or settling into fixed spots.',
},
{
id: 'mitosis',
label: 'mitosis',
F: 0.018,
k: 0.051,
blurb:
'The most restless of the five: fronts keep buckling and pinching off new branches faster than they can settle, so the boundary between chemicals never stops rearranging itself.',
},
{
id: 'waves',
label: 'waves',
F: 0.014,
k: 0.045,
blurb:
'Further out from the other regimes, the field never settles at all — spots and fronts keep forming, colliding, and annihilating, closer to a chemical wave than a fixed pattern.',
},
];
function buildWrapTables(n: number): { prev: Int32Array; next: Int32Array } {
const prev = new Int32Array(n);
const next = new Int32Array(n);
for (let i = 0; i < n; i++) {
prev[i] = (i - 1 + n) % n;
next[i] = (i + 1) % n;
}
return { prev, next };
}
// Mulberry32 -- small, fast, deterministic-if-needed PRNG. Good enough for
// visual seeding; no cryptographic or statistical requirements here.
function mulberry32(seed: number) {
let a = seed;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function ReactionDiffusion() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [presetId, setPresetId] = useState(PRESETS[0].id);
const [F, setF] = useState(PRESETS[0].F);
const [k, setK] = useState(PRESETS[0].k);
const [playing, setPlaying] = useState(true);
const [stepsPerFrame, setStepsPerFrame] = useState(8);
const FRef = useRef(F);
const kRef = useRef(k);
const playingRef = useRef(playing);
const stepsRef = useRef(stepsPerFrame);
const reseedRef = useRef<() => void>(() => {});
useEffect(() => {
FRef.current = F;
}, [F]);
useEffect(() => {
kRef.current = k;
}, [k]);
useEffect(() => {
playingRef.current = playing;
}, [playing]);
useEffect(() => {
stepsRef.current = stepsPerFrame;
}, [stepsPerFrame]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = GRID_W;
canvas.height = GRID_H;
const n = GRID_W * GRID_H;
let u = new Float32Array(n);
let v = new Float32Array(n);
let u2 = new Float32Array(n);
let v2 = new Float32Array(n);
const { prev: xPrev, next: xNext } = buildWrapTables(GRID_W);
const { prev: yPrev, next: yNext } = buildWrapTables(GRID_H);
const rng = mulberry32(Date.now() & 0xffffffff);
const reseed = () => {
u.fill(1);
v.fill(0);
// A handful of small isolated blobs settle into stable localized dots
// and never trigger the growth/splitting/merging instabilities that
// give each regime its character -- confirmed by watching the field
// freeze into a static sparse pattern within a second of real time.
// Larger, denser blobs give the reaction front enough contiguous area
// to actually invade the surrounding field.
const blobs = 4 + Math.floor(rng() * 3);
for (let b = 0; b < blobs; b++) {
const cx = Math.floor(rng() * GRID_W);
const cy = Math.floor(rng() * GRID_H);
const r = 7 + Math.floor(rng() * 5);
for (let dy = -r; dy < r; dy++) {
for (let dx = -r; dx < r; dx++) {
const x = (cx + dx + GRID_W) % GRID_W;
const y = (cy + dy + GRID_H) % GRID_H;
const i = y * GRID_W + x;
u[i] = 0.5 + (rng() - 0.5) * 0.02;
v[i] = 0.25 + (rng() - 0.5) * 0.02;
}
}
}
};
reseedRef.current = reseed;
reseed();
const step = () => {
const f = FRef.current;
const kk = kRef.current;
for (let y = 0; y < GRID_H; y++) {
const yUp = yPrev[y] * GRID_W;
const yDown = yNext[y] * GRID_W;
const yMid = y * GRID_W;
for (let x = 0; x < GRID_W; x++) {
const xL = xPrev[x];
const xR = xNext[x];
const i = yMid + x;
const lu =
0.05 * (u[yUp + xL] + u[yUp + xR] + u[yDown + xL] + u[yDown + xR]) +
0.2 * (u[yMid + xL] + u[yMid + xR] + u[yUp + x] + u[yDown + x]) -
u[i];
const lv =
0.05 * (v[yUp + xL] + v[yUp + xR] + v[yDown + xL] + v[yDown + xR]) +
0.2 * (v[yMid + xL] + v[yMid + xR] + v[yUp + x] + v[yDown + x]) -
v[i];
const uu = u[i];
const vv = v[i];
const reaction = uu * vv * vv;
u2[i] = uu + (Du * lu - reaction + f * (1 - uu)) * DT;
v2[i] = vv + (Dv * lv + reaction - (f + kk) * vv) * DT;
}
}
[u, u2] = [u2, u];
[v, v2] = [v2, v];
};
const imageData = ctx.createImageData(GRID_W, GRID_H);
const pixels = imageData.data;
const draw = () => {
for (let i = 0; i < n; i++) {
const t = Math.max(0, Math.min(1, v[i] / V_SCALE));
const p = i * 4;
pixels[p] = BG[0] + (FG[0] - BG[0]) * t;
pixels[p + 1] = BG[1] + (FG[1] - BG[1]) * t;
pixels[p + 2] = BG[2] + (FG[2] - BG[2]) * t;
pixels[p + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
};
let raf = 0;
const tick = () => {
if (playingRef.current) {
for (let i = 0; i < stepsRef.current; i++) step();
draw();
}
raf = requestAnimationFrame(tick);
};
draw();
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
const loadPreset = (preset: Preset) => {
setPresetId(preset.id);
setF(preset.F);
setK(preset.k);
reseedRef.current();
};
const activePreset = PRESETS.find((p) => p.id === presetId);
return (
<div className="reaction-diffusion">
<canvas
ref={canvasRef}
className="rd-canvas"
aria-label="reaction-diffusion pattern"
role="img"
/>
<div className="controls">
<div className="group">
<span className="group-label">pattern</span>
{PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
className={presetId === preset.id ? 'active' : ''}
onClick={() => loadPreset(preset)}
>
{preset.label}
</button>
))}
</div>
<button type="button" onClick={() => setPlaying((p) => !p)}>
{playing ? 'pause' : 'play'}
</button>
<button
type="button"
onClick={() => {
setPresetId('');
reseedRef.current();
}}
>
reseed
</button>
</div>
<div className="sliders">
<label>
feed rate (F) {F.toFixed(4)}
<input
type="range"
min={0.01}
max={0.09}
step={0.0005}
value={F}
onChange={(e) => {
setPresetId('');
setF(Number(e.target.value));
}}
/>
</label>
<label>
kill rate (k) {k.toFixed(4)}
<input
type="range"
min={0.03}
max={0.08}
step={0.0005}
value={k}
onChange={(e) => {
setPresetId('');
setK(Number(e.target.value));
}}
/>
</label>
<label>
speed
<input
type="range"
min={1}
max={20}
step={1}
value={stepsPerFrame}
onChange={(e) => setStepsPerFrame(Number(e.target.value))}
/>
</label>
</div>
<p className="blurb">
{activePreset
? activePreset.blurb
: 'Custom (F, k) — drag either slider while it runs and watch the pattern reorganize live.'}
</p>
<style>{`
.reaction-diffusion {
border: 1px solid #2a2d31;
background: #0e1113;
}
.rd-canvas {
display: block;
width: 100%;
aspect-ratio: ${GRID_W} / ${GRID_H};
border-bottom: 1px solid #2a2d31;
image-rendering: auto;
}
.reaction-diffusion .controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem 0.5rem;
}
.reaction-diffusion .group {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.reaction-diffusion .group-label {
color: var(--muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-right: 0.15rem;
}
.reaction-diffusion .controls button {
font-family: inherit;
font-size: 0.75rem;
color: #8a8f98;
background: rgba(0, 0, 0, 0.4);
border: 1px solid #2a2d31;
padding: 0.25rem 0.6rem;
cursor: pointer;
}
.reaction-diffusion .controls button:hover {
color: #e6e6e6;
}
.reaction-diffusion .controls button.active {
color: #0b0d0f;
background: var(--accent);
border-color: var(--accent);
}
.reaction-diffusion .sliders {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
padding: 0 1rem 0.75rem;
}
.reaction-diffusion .sliders label {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--muted);
font-size: 0.75rem;
}
.reaction-diffusion .blurb {
color: var(--muted);
font-size: 0.8rem;
margin: 0;
padding: 0 1rem 1rem;
max-width: 60ch;
}
`}</style>
</div>
);
}