← code

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.

pattern

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>
  );
}