Skip to main content

ns-ui / loud

Agar Starve

A bacterial colony grown from a live, depleting nutrient field: a rich plate spreads into a smooth disc, a starved one forks into dense-branching fingers that never merge because the gap between them was eaten first, and a spent plate dries to interior tone before a fresh colony sprouts from another corner and grows through the ghost of the last one.

Use when Pick agar-starve when the backdrop should read as a live resource-limited growth process — a colony whose branching or smoothness is a direct, inspectable consequence of a nutrient field it is depleting as it spreads, cycling through exhaustion and reseeding at another corner while the old colony's shadow stays visible underneath. Pick rime-creep instead when the growth should be a fixed crystallographic lattice (dendrites at an exact 60-degree angle set, colliding into grain boundaries) with no resource memory driving its shape — rime-creep's morphology never changes no matter how long you watch, agar-starve's does. Pick hero-ascii-reaction-front when you want a constant-speed excitable chemical wave (Gray-Scott fronts) rather than a front that can slow, starve and stop advancing in place.

Install

npx shadcn add https://design.helpmarq.com/r/agar-starve.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first — paste it in once the chat opens.

Source
registry/loud/agar-starve/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// AgarStarve — a bacterial colony whose SHAPE is decided by a live, depleting
// nutrient field, not by noise or a growth rule with a fixed geometry.
//
// Two coupled lattices at roughly 1/6 viewport resolution (coarsened further
// under a cell budget, see MAX_CELLS): OCCUPIED (has biomass ever landed
// here — permanent, cells never vacate) and N, a nutrient concentration that
// diffuses and gets eaten. Every tick, in order:
//
//   1. DIFFUSE — N relaxes toward a smoothed version of itself. This is
//      solved IMPLICITLY via Jacobi relaxation (Stam's stable-diffuse: each
//      iteration reads the previous iteration's WHOLE field, never itself
//      mid-sweep), 5 iterations, no-flux at the plate edge. Fewer iterations
//      leaves the solve unconverged in a way that is biased along the two
//      grid axes — branches would visibly snap to 90deg, the classic
//      lattice-diffusion artifact. That the caller can raise `diffusion` at
//      runtime and watch an already-branched colony round itself back out is
//      the falsifiable core of this component: nothing here is baked
//      geometry, it's a PDE the colony is currently reading.
//   2. REACT — every occupied cell eats CONSUME from its own nutrient. A
//      cell whose local N has fallen under STARVE stops eating and stops
//      dividing — "dies to inert interior" — but the check is against LIVE
//      N, not a sticky flag, so a cell a rising diffusion front reaches
//      really does wake back up.
//   3. DIVIDE — every still-alive occupied cell picks ONE random empty
//      neighbour (Moore, 8-connected — a 4-connected Eden model grows a
//      visible diamond) and divides into it with probability = that
//      neighbour's OWN nutrient, capped at 0.25. This is the whole
//      mechanism, and it is nutrient-limited division, not DLA or an
//      L-system: no direction is ever chosen on purpose, but a neighbour
//      facing untouched plate has more nutrient and wins the coin flip more
//      often over many ticks, so fingers statistically steer toward what's
//      still fresh. And because the same diffuse+eat step also pulls the
//      nutrient OUT of the gap between two adjacent fingers before either
//      tip physically reaches it, two fingers advancing at each other starve
//      the seam between them first and never touch — a gap the render pass
//      paints as permanent exhausted shadow, because N there truly never
//      recovers on its own.
//
// C0 (prop `c0`) is the initial fill of N and the real governing scalar —
// the axis of the Ben-Jacob morphology diagram. High C0 keeps most of the
// plate above the 0.25 probability cap for long enough that the front
// advances near-uniformly (a smooth Eden disc); low C0 exhausts locally
// almost immediately behind the leading edge, so growth channels into the
// few directions that still have supply (dense-branching morphology).
//
// When no occupied+alive cell has an empty neighbour left for a sustained
// stretch (DRY_GRACE_TICKS), the plate is spent: every occupied cell is
// snapped to the oldest age band ("dries to interior tone") and, after a
// pause, a fresh inoculation is dropped at the next corner in rotation, N is
// refilled everywhere — including inside the old shadow gaps — and the new
// colony grows outward, free to fill in territory the previous colony's
// gaps had starved shut. Occupancy is never cleared, so the old colony's
// silhouette stays legible underneath: growing through the ghost of the
// last one.
//
// RENDER: the two lattices are painted into an ImageData at grid resolution
// (occupied cells ramp --ns-muted at birth toward --border at full age;
// empty-but-depleted cells get a faint --border wash standing in for
// exhausted shadow, everything else fully transparent over the
// bg-background wrapper) then drawn scaled up onto the visible canvas with
// a 1.5px canvas blur, so the result reads as biomass texture rather than
// as visible grid cells. Tokens are read via getComputedStyle at mount and
// re-read on a documentElement class mutation (theme toggle); the ramp
// never leaves the --ns-muted..--border range, so it can never get darker
// (dark theme) or busier (light theme) than --border, which is what keeps
// overlaid text safe.
//
// No pointer/keyboard interaction anywhere — this is a decorative backdrop,
// the canvas is aria-hidden, and the whole point is a slow ambient process
// that rewards an occasional glance rather than a stare: the front advances
// at roughly 8px/s. prefers-reduced-motion runs the same tick function
// REDUCED_TICKS times synchronously at mount and paints one static frame
// instead of ever scheduling a rAF.
// ---------------------------------------------------------------------------

const CELL_BASE = 6; // 1/6 viewport resolution, before the cell budget below
const MAX_CELLS = 9000; // perf cap: cell size grows past CELL_BASE on big panes

const DIV_PROB_CAP = 0.25;
const CONSUME = 0.02;
const STARVE = 0.03;
const JACOBI_ITERS = 5;

// Ticks for a cell to fully age from --ns-muted to --border. 500 (~100s), not
// the original 40 (~8s): --border is a near-invisible wash against
// --background, so an 8-second ramp meant everything but the advancing margin
// itself had already faded out, and the colony body — the thing that records
// where the nutrient went — was never on screen at all. At 500 the last couple
// of minutes of growth stay legible and fade off behind the front.
const MATURE_AGE = 500;
const TICK_HZ = 5; // 0.25 max prob * 5 ticks/s * ~6-10px cell ~= 8px/s front
const TICK_STEP = 1 / TICK_HZ;
const MAX_TICKS_PER_FRAME = 6; // catch-up clamp after a stalled tab

const DRY_GRACE_TICKS = 15; // ~3s with no room left before we call it spent
const DRY_PAUSE_TICKS = 30; // ~6s showing the dried plate before reseeding
const SEED_RADIUS = 1;
const CORNER_MARGIN = 2;
const SHADOW_MAX_ALPHA = 70; // out of 255 — a wash, not a second colony

// Normal-motion mount warmup. Deliberately large: the colony grows at ~8px/s,
// so a small warmup leaves first paint (and the resting screenshot the owner
// judges first) showing a bare corner wedge on an otherwise empty plate, with
// none of the branching the piece is about. Matches the prewarm-at-mount idiom
// floret-pack and thallus-siege already use.
// Safety bound only — the prewarm below stops on COVERAGE, not on this count.
// A fixed count ran past the end of a whole plate cycle on a wide pane: first
// paint was a spent plate (every occupied cell aged to --border, a wash that is
// near-invisible against --background) plus a just-reseeded corner dot, i.e. an
// apparently blank pane through the gate.
const PREWARM_TICKS = 900;
/** fraction of the lattice the mount prewarm grows to before it stops */
const WARM_COVERAGE = 0.3;
const WARM_COVERAGE_REDUCED = 0.55;
const REDUCED_TICKS = 3000; // reduced-motion: one fully-grown static frame

const NX = [-1, 0, 1, -1, 1, -1, 0, 1];
const NY = [-1, -1, -1, 0, 0, 1, 1, 1];

type RGB = [number, number, number];

function parseHex(raw: string): RGB | null {
  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());
  if (!m) return null;
  let h = m[1];
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  const n = parseInt(h, 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

function mixRGB(a: RGB, b: RGB, t: number): RGB {
  const k = t < 0 ? 0 : t > 1 ? 1 : t;
  return [
    Math.round(a[0] + (b[0] - a[0]) * k),
    Math.round(a[1] + (b[1] - a[1]) * k),
    Math.round(a[2] + (b[2] - a[2]) * k),
  ];
}

function smoothstep(a: number, b: number, x: number): number {
  const t = Math.max(0, Math.min(1, (x - a) / (b - a)));
  return t * t * (3 - 2 * t);
}

// xorshift32 — deterministic across mounts, unlike Math.random, so the
// resting frame this component ships doesn't differ screenshot to screenshot
function makeRng(seed: number) {
  let s = seed >>> 0 || 1;
  return () => {
    s ^= s << 13;
    s ^= s >>> 17;
    s ^= s << 5;
    return ((s >>> 0) % 1_000_000) / 1_000_000;
  };
}

export interface AgarStarveProps {
  /** Initial nutrient concentration (0-1) — the governing scalar of the Ben-Jacob morphology diagram. Low starves into dense-branching fingers; high grows a smooth Eden disc. @default 0.34 */
  c0?: number;
  /** Nutrient diffusion coefficient, cell^2/step. Raise it at runtime to visibly heal an already-branched colony back toward circular. @default 0.18 */
  diffusion?: number;
  /** Global simulation speed multiplier. @default 1 */
  speed?: number;
  /** Freezes the plate on its current frame without unmounting. */
  paused?: boolean;
  /** Rendered in the DOM over the plate — this layer alone is aria-hidden. */
  children?: React.ReactNode;
  className?: string;
  style?: React.CSSProperties;
}

export function AgarStarve({
  c0 = 0.34,
  diffusion = 0.18,
  speed = 1,
  paused = false,
  children,
  className = "",
  style,
}: AgarStarveProps) {
  const wrapRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  // read live every render, consulted inside the effect via .current — none
  // of these should tear down and reseed the plate just because a prop
  // changed, that's the whole point of "heal at runtime"
  const c0Ref = useRef(c0);
  c0Ref.current = c0;
  const diffusionRef = useRef(diffusion);
  diffusionRef.current = diffusion;
  const speedRef = useRef(speed);
  speedRef.current = speed;
  const pausedRef = useRef(paused);
  pausedRef.current = paused;

  useEffect(() => {
    const wrap = wrapRef.current;
    const canvas = canvasRef.current;
    if (!wrap || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduced = mq.matches;

    let cssW = 0;
    let cssH = 0;
    let dpr = 1;
    let cols = 0;
    let rows = 0;
    let cellSize = CELL_BASE;

    // --- lattices -----------------------------------------------------------
    let N = new Float32Array(0);
    let N0 = new Float32Array(0);
    let Nb = new Float32Array(0);
    let occupied = new Uint8Array(0);
    let age = new Uint16Array(0);
    let aliveBuf = new Uint8Array(0);
    let plateC0 = c0Ref.current;

    let rng = makeRng(0x9e3779b9);
    let cornerIdx = 0;
    let dryTimer = 0;
    let reseedTimer = 0;
    let state: "growing" | "dried" = "growing";

    const idx = (x: number, y: number) => y * cols + x;

    const cornerPos = (i: number): [number, number] => {
      const m = CORNER_MARGIN;
      switch (i % 4) {
        case 0:
          return [m, m];
        case 1:
          return [cols - 1 - m, rows - 1 - m];
        case 2:
          return [cols - 1 - m, m];
        default:
          return [m, rows - 1 - m];
      }
    };

    // expanding square-ring search for the nearest empty cell to (cx, cy)
    const findSeedSpot = (cx: number, cy: number): [number, number] => {
      const maxR = Math.max(cols, rows);
      for (let r = 0; r <= maxR; r++) {
        for (let dy = -r; dy <= r; dy++) {
          for (let dx = -r; dx <= r; dx++) {
            if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
            const x = cx + dx;
            const y = cy + dy;
            if (x < 0 || y < 0 || x >= cols || y >= rows) continue;
            if (occupied[idx(x, y)] === 0) return [x, y];
          }
        }
      }
      // the whole plate is biomass — force a clean spot rather than never
      // reseeding again
      return [cx, cy];
    };

    const plantSeed = (cx: number, cy: number) => {
      for (let dy = -SEED_RADIUS; dy <= SEED_RADIUS; dy++) {
        for (let dx = -SEED_RADIUS; dx <= SEED_RADIUS; dx++) {
          if (dx * dx + dy * dy > SEED_RADIUS * SEED_RADIUS + 0.5) continue;
          const x = cx + dx;
          const y = cy + dy;
          if (x < 0 || y < 0 || x >= cols || y >= rows) continue;
          const i = idx(x, y);
          occupied[i] = 1;
          age[i] = 0;
        }
      }
    };

    const reseed = (fresh: boolean) => {
      plateC0 = Math.max(0.05, Math.min(1, c0Ref.current));
      N.fill(plateC0);
      if (fresh) {
        occupied.fill(0);
        age.fill(0);
        cornerIdx = 0;
      } else {
        cornerIdx++;
      }
      const [cx, cy] = cornerPos(cornerIdx);
      const [sx, sy] = findSeedSpot(cx, cy);
      plantSeed(sx, sy);
      state = "growing";
      dryTimer = 0;
      reseedTimer = 0;
    };

    // Jacobi solve of (I - a*Laplacian) N = N0, no-flux boundary (an edge
    // cell's missing neighbour is itself — nutrient neither leaks off the
    // plate nor wraps around it)
    const diffuseStep = () => {
      const a = Math.max(0, diffusionRef.current);
      const denom = 1 / (1 + 4 * a);
      N0.set(N);
      let src = N;
      let dst = Nb;
      for (let it = 0; it < JACOBI_ITERS; it++) {
        for (let y = 0; y < rows; y++) {
          const up = y > 0 ? y - 1 : y;
          const dn = y < rows - 1 ? y + 1 : y;
          const rowY = y * cols;
          const rowUp = up * cols;
          const rowDn = dn * cols;
          for (let x = 0; x < cols; x++) {
            const lf = x > 0 ? x - 1 : x;
            const rt = x < cols - 1 ? x + 1 : x;
            const i = rowY + x;
            const sum = src[rowY + lf]! + src[rowY + rt]! + src[rowUp + x]! + src[rowDn + x]!;
            dst[i] = (N0[i]! + a * sum) * denom;
          }
        }
        const t = src;
        src = dst;
        dst = t;
      }
      N = src;
      Nb = dst;
    };

    const pending: number[] = [];
    const neighborScratch: number[] = [];

    const reactAndDivide = () => {
      let active = 0;
      pending.length = 0;
      for (let y = 0; y < rows; y++) {
        const rowY = y * cols;
        for (let x = 0; x < cols; x++) {
          const i = rowY + x;
          if (!occupied[i]) continue;
          const wasAlive = N[i]! >= STARVE;
          if (!wasAlive) {
            aliveBuf[i] = 0;
            age[i] = MATURE_AGE;
            continue;
          }
          N[i] = Math.max(0, N[i]! - CONSUME);
          age[i] = age[i]! < MATURE_AGE ? age[i]! + 1 : MATURE_AGE;
          const stillAlive = N[i]! >= STARVE;
          aliveBuf[i] = stillAlive ? 1 : 0;
          if (!stillAlive) continue;

          neighborScratch.length = 0;
          for (let k = 0; k < 8; k++) {
            const nx = x + NX[k]!;
            const ny = y + NY[k]!;
            if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
            const ni = ny * cols + nx;
            if (occupied[ni] === 0) neighborScratch.push(ni);
          }
          if (neighborScratch.length === 0) continue;
          active++;
          const target = neighborScratch[Math.floor(rng() * neighborScratch.length)]!;
          const prob = Math.min(DIV_PROB_CAP, N[target]!);
          if (rng() < prob) pending.push(target);
        }
      }
      for (let k = 0; k < pending.length; k++) {
        const i = pending[k]!;
        if (occupied[i]) continue; // two front cells targeted the same gap
        occupied[i] = 1;
        age[i] = 0;
      }
      return active;
    };

    const tick = () => {
      diffuseStep();
      const active = reactAndDivide();
      if (active === 0) {
        dryTimer++;
        if (state === "growing" && dryTimer >= DRY_GRACE_TICKS) {
          state = "dried";
          reseedTimer = 0;
          // the whole plate dries to interior tone in one beat, not just
          // whichever cells individually starved this tick
          for (let i = 0; i < occupied.length; i++) {
            if (occupied[i]) age[i] = MATURE_AGE;
          }
        }
      } else {
        dryTimer = 0;
        if (state === "dried") state = "growing";
      }
      if (state === "dried") {
        reseedTimer++;
        if (reseedTimer >= DRY_PAUSE_TICKS) reseed(false);
      }
    };

    // --- palette --------------------------------------------------------
    let muted: RGB = [79, 79, 79];
    let border: RGB = [46, 46, 46];
    const readColors = () => {
      const cs = getComputedStyle(document.documentElement);
      muted = parseHex(cs.getPropertyValue("--ns-muted")) ?? muted;
      border = parseHex(cs.getPropertyValue("--border")) ?? border;
    };
    readColors();

    // --- offscreen grid canvas, upsampled + blurred onto the visible one --
    let off: HTMLCanvasElement | null = null;
    let offCtx: CanvasRenderingContext2D | null = null;
    let img: ImageData | null = null;

    const buildOffscreen = () => {
      off = document.createElement("canvas");
      off.width = cols;
      off.height = rows;
      offCtx = off.getContext("2d");
      img = offCtx ? offCtx.createImageData(cols, rows) : null;
    };

    const render = () => {
      if (!offCtx || !img || cols <= 0 || rows <= 0) return;
      const data = img.data;
      for (let i = 0, p = 0; i < N.length; i++, p += 4) {
        if (occupied[i]) {
          const t = age[i]! / MATURE_AGE;
          const c = mixRGB(muted, border, t);
          data[p] = c[0];
          data[p + 1] = c[1];
          data[p + 2] = c[2];
          data[p + 3] = 255;
        } else {
          const depletion = 1 - N[i]! / plateC0;
          const shadow = smoothstep(0.35, 0.95, depletion);
          data[p] = border[0];
          data[p + 1] = border[1];
          data[p + 2] = border[2];
          data[p + 3] = Math.round(shadow * SHADOW_MAX_ALPHA);
        }
      }
      offCtx.putImageData(img, 0, 0);
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.imageSmoothingEnabled = true;
      ctx.filter = "blur(1.5px)";
      ctx.drawImage(off!, 0, 0, cols, rows, 0, 0, canvas.width, canvas.height);
      ctx.filter = "none";
    };

    // --- sizing -----------------------------------------------------------
    const rebuild = () => {
      if (cssW < 2 || cssH < 2) return;
      cellSize = CELL_BASE;
      let c = Math.max(4, Math.ceil(cssW / cellSize));
      let r = Math.max(4, Math.ceil(cssH / cellSize));
      while (c * r > MAX_CELLS) {
        cellSize++;
        c = Math.max(4, Math.ceil(cssW / cellSize));
        r = Math.max(4, Math.ceil(cssH / cellSize));
      }
      cols = c;
      rows = r;
      const n = cols * rows;
      N = new Float32Array(n);
      N0 = new Float32Array(n);
      Nb = new Float32Array(n);
      occupied = new Uint8Array(n);
      age = new Uint16Array(n);
      aliveBuf = new Uint8Array(n);
      rng = makeRng(0x9e3779b9);
      buildOffscreen();
      reseed(true);
      // Prewarm to a COVERAGE target, not to a tick count. A fixed count can't
      // be right for two reasons: the front's speed depends on cell size (so on
      // the pane), and a plate is spent after a bounded number of ticks — a
      // count tuned past that lands on the dried/reseeded phase, where the
      // interior has aged to --border and the whole pane reads blank. Stopping
      // at a fraction of the lattice always lands mid-growth, with the
      // branching margin on screen, whatever the viewport. The tick count
      // below is only a safety bound.
      const warmCap = reduced ? REDUCED_TICKS : PREWARM_TICKS;
      const warmTarget = (reduced ? WARM_COVERAGE_REDUCED : WARM_COVERAGE) * n;
      for (let i = 0; i < warmCap; i++) {
        tick();
        if ((i & 7) === 7) {
          let filled = 0;
          for (let k = 0; k < n; k++) if (occupied[k]) filled++;
          if (filled >= warmTarget) break;
        }
      }
      render();
    };

    const applyBacking = () => {
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.max(1, Math.round(cssW * dpr));
      canvas.height = Math.max(1, Math.round(cssH * dpr));
      canvas.style.width = `${cssW}px`;
      canvas.style.height = `${cssH}px`;
    };

    let rebuildTimer = 0;
    const resize = () => {
      const rect = wrap.getBoundingClientRect();
      if (rect.width < 2 || rect.height < 2) return;
      const changed = Math.abs(rect.width - cssW) > 0.5 || Math.abs(rect.height - cssH) > 0.5;
      cssW = rect.width;
      cssH = rect.height;
      applyBacking();
      if (changed) {
        if (cols === 0) {
          rebuild();
        } else {
          window.clearTimeout(rebuildTimer);
          rebuildTimer = window.setTimeout(rebuild, 260);
        }
      } else {
        render();
      }
    };

    // --- loop ---------------------------------------------------------------
    let raf = 0;
    let last = 0;
    let acc = 0;
    let visible = true;
    let staticMode = reduced || pausedRef.current;

    const loop = (now: number) => {
      const dt = last === 0 ? 1 / 60 : Math.min(0.1, (now - last) / 1000);
      last = now;
      acc += dt * Math.max(0, speedRef.current);
      let ran = 0;
      while (acc >= TICK_STEP && ran < MAX_TICKS_PER_FRAME) {
        tick();
        acc -= TICK_STEP;
        ran++;
      }
      if (ran > 0) render();
      if (visible && !document.hidden && !staticMode) {
        raf = requestAnimationFrame(loop);
      } else {
        raf = 0;
      }
    };

    const wake = () => {
      if (raf || staticMode || !visible || document.hidden) return;
      last = 0;
      raf = requestAnimationFrame(loop);
    };
    const sleep = () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };

    const ro = new ResizeObserver(resize);
    ro.observe(wrap);
    resize();

    const io = new IntersectionObserver(
      (entries) => {
        visible = entries.some((e) => e.isIntersecting);
        if (visible) wake();
        else sleep();
      },
      { threshold: 0 }
    );
    io.observe(wrap);

    const onVis = () => {
      if (document.hidden) sleep();
      else wake();
    };
    document.addEventListener("visibilitychange", onVis);

    // the REDUCED_TICKS prewarm only ever ran under the reduced-motion value
    // seen at mount; a LIVE flip just freezes/resumes wherever growth
    // currently is, same as toggling `paused`
    const applyMode = () => {
      staticMode = reduced || pausedRef.current;
      if (staticMode) sleep();
      else wake();
    };
    const onMq = () => {
      reduced = mq.matches;
      applyMode();
    };
    mq.addEventListener("change", onMq);

    let lastPolledPaused = pausedRef.current;
    let poll = 0;
    const pollPaused = () => {
      if (pausedRef.current !== lastPolledPaused) {
        lastPolledPaused = pausedRef.current;
        applyMode();
      }
      poll = window.setTimeout(pollPaused, 150);
    };
    pollPaused();

    const themeObserver = new MutationObserver(() => {
      readColors();
      render();
    });
    themeObserver.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    applyMode();

    return () => {
      sleep();
      ro.disconnect();
      io.disconnect();
      themeObserver.disconnect();
      mq.removeEventListener("change", onMq);
      document.removeEventListener("visibilitychange", onVis);
      window.clearTimeout(rebuildTimer);
      window.clearTimeout(poll);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div
      ref={wrapRef}
      className={`relative isolate h-full w-full overflow-hidden bg-background ${className}`}
      style={style}
    >
      <canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 block h-full w-full" />
      {children ? <div className="relative z-[1] h-full w-full">{children}</div> : null}
    </div>
  );
}

AgarStarve.displayName = "AgarStarve";
Build spec

Build <AgarStarve c0? diffusion? speed? paused? children? className? style?> as a full-bleed canvas colony whose shape is decided by a live nutrient field it is currently consuming, not by noise or a fixed growth rule. Two coupled lattices run at roughly 1/6 viewport resolution (coarsened further under an internal cell budget so huge panes stay cheap): OCCUPIED, a permanent biomass grid that cells never leave once colonized, and N, a nutrient concentration that diffuses and gets eaten. Every simulation tick, in order: DIFFUSE — N is relaxed via 5 Jacobi iterations solving the implicit diffusion equation (Stam-style: each iteration reads the previous iteration's whole field, never itself mid-sweep) with a no-flux boundary at the plate edge; fewer iterations leaves the solve visibly biased along the two grid axes and branches snap to 90 degrees, the classic lattice-diffusion artifact. REACT — every occupied cell eats 0.02 nutrient from its own cell each tick; once its local nutrient drops under a starvation threshold it stops eating and stops dividing ("dies to inert interior"), and that check is against the LIVE field rather than a sticky flag, so a cell a rising diffusion front reaches genuinely wakes back up. DIVIDE — every still-alive occupied cell picks ONE random empty Moore (8-connected) neighbour and divides into it with probability equal to that neighbour's own nutrient, clamped at 0.25; a 4-connected rule would grow a visible diamond instead of a disc. Nothing here ever points a finger on purpose: a neighbour facing untouched plate simply has more nutrient and wins the coin flip more often over many ticks, which is what makes fingers statistically steer toward what is still fresh, and the same diffuse-then-eat step pulls nutrient OUT of the gap between two fingers advancing at each other before either tip physically reaches it, so the seam starves first and the two never touch — the render pass paints that gap as permanent exhausted shadow because its nutrient truly never recovers on its own. `c0` (initial nutrient concentration, default 0.34) is the governing scalar and the real axis of the Ben-Jacob morphology diagram: a high value keeps most of the plate above the 0.25 probability cap long enough that the front advances near-uniformly into a smooth Eden disc, a low one exhausts locally almost immediately behind the leading edge and growth channels into whichever few directions still have supply, producing dense-branching fingers. `diffusion` (default 0.18, cell^2/step) is read live every tick rather than only at mount, so raising it at runtime visibly heals an already-branched colony: faster relaxation refills the starved gaps between fingers before the boundary cells there die for good, and cells that are still viable resume dividing into territory the branching had shut. When no occupied, still-alive cell has an empty neighbour left for a sustained stretch, the plate is spent: every occupied cell is snapped to its oldest age band in one beat ("the colony dries to interior tone"), and after a pause a fresh inoculation is planted at the next corner in rotation, nutrient is refilled everywhere including inside the previous colony's shadow gaps, and the new colony grows outward free to fill territory the old one's depletion had sealed off — occupancy is never cleared, so the old colony's silhouette stays legible underneath while the new one grows through its ghost. RENDER: the two lattices are painted into an ImageData at grid resolution — occupied cells ramp from --ns-muted at the newly-divided rim toward --border at full age, empty-but-depleted cells get a faint --border wash standing in for exhausted shadow, everything else is fully transparent over the bg-background wrapper — then drawn upsampled onto the visible canvas with a 1.5px canvas blur so the result reads as biomass texture rather than visible grid cells. --ns-muted and --border are read via getComputedStyle(document.documentElement) at mount and re-read on a documentElement class mutation (theme toggle); the color ramp never leaves that --ns-muted..--border range, so biomass can never get darker in dark mode or busier in light mode than --border, which is what keeps overlaid text safe in both themes. There is no pointer or keyboard interaction anywhere: the canvas is aria-hidden, the wrapper renders `children` as ordinary accessible DOM over the plate, and the whole point is a slow ambient process meant to reward an occasional glance rather than a stare — the front advances at roughly 8px/s. `paused` freezes the current frame without unmounting (polled via a ref rather than an effect dependency, so toggling it does not tear down and reseed the plate); `speed` is a global tick-rate multiplier, also read live. prefers-reduced-motion runs the same tick function 3000 times synchronously at mount and paints one static, fully-grown frame instead of ever scheduling a rAF loop, and a live change to the media query freezes or resumes growth from wherever it currently is. ResizeObserver rebuilds both lattices (debounced) on a real size change; IntersectionObserver and visibilitychange stop the single rAF loop off-screen or in a hidden tab. A deterministic xorshift PRNG (not Math.random) drives every random neighbour pick and division roll, so the shipped resting frame is reproducible mount to mount rather than drifting between screenshots.

Props

PropTypeDefaultDescription
c0?number0.34Initial nutrient concentration (0-1) — the governing scalar of the Ben-Jacob morphology diagram. Low starves into dense-branching fingers; high grows a smooth Eden disc. @default 0.34
diffusion?number0.18Nutrient diffusion coefficient, cell^2/step. Raise it at runtime to visibly heal an already-branched colony back toward circular. @default 0.18
speed?number1Global simulation speed multiplier. @default 1
paused?booleanfalseFreezes the plate on its current frame without unmounting.
children?React.ReactNodeRendered in the DOM over the plate — this layer alone is aria-hidden.
className?string
style?React.CSSProperties