shadimage
ns-ui
uiagar-starvesource: ok · hit

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.

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

No selection tags yet — empty means no evidence, not lack of support.

Files1 · source cached 10/9/2026, 12:21:53 AM

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";

More from ns-ui

Browse all components in this library →