Skip to main content

ns-ui / loud

Thallus Siege

A full-bleed pane of crustose lichen: a dozen colonies grow lobed radial fronts across a shared occupancy lattice, stall on contact, occasionally creep over a markedly slower rival, and senesce from the centre outward to reopen ground the mosaic keeps renegotiating on a scale of minutes, not frames.

Use when Pick thallus-siege for a careers or ecosystem backdrop that should read as a quiet, textured mosaic first and only reveal that it is a live territorial simulation on a return visit — growth is glacial by design, contact-inhibited with occasional slow reconquest, and old centres visibly reopen and get reclaimed. Pick background-ascii-voronoi-walls or background-lloyd-relax instead when the partition should read as instantly computed from seed geometry (crisp cell edges settling in place) rather than grown from advancing, colliding fronts; pick rime-creep when the growth itself should be the visibly fast, foreground event rather than a quiet backdrop.

Install

npx shadcn add https://design.helpmarq.com/r/thallus-siege.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/thallus-siege/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// ThallusSiege — a pane of crustose lichen fighting a slow, never-finished war
// for the same patch of rock.
//
// TERRITORY IS GROWN, NOT PARTITIONED. A dozen concurrent "thalli" nucleate at
// staggered moments on bare substrate. Each grows a front of ~180 radial
// spokes; every spoke advances at its own thallus's rate, scaled by a slow
// angular/temporal noise term so the margin comes out lobed rather than a
// clean circle. A spoke stamps the cell it enters into a shared occupancy
// lattice (owner id + claim time). Stepping into a cell another thallus
// already owns kills the spoke's advance there UNLESS this thallus's rate
// exceeds the rival's by more than 1.3x, in which case it keeps creeping in
// at (ratio - 1.3) * base — slow, but real reconquest, not a redraw. Nothing
// here ever asks "which seed is nearest" — the tells that would give away a
// Voronoi/distance-field impostor (boundaries bowing toward the slow side,
// a late seed walled into a concave cell, a centre that never reopens) are
// exactly the behaviours this front-growth mechanism produces for free.
//
// THE GOVERNING SCALAR is growth-rate variance across thalli (the `variance`
// prop). At variance = 0 every thallus grows at the same pace, the 1.3x
// dead-band is never crossed, no front ever reconquers another, and the
// mosaic settles into flat, static, roughly-equidistant cells — a fair
// near-Voronoi partition in every way but how it was computed. That is the
// exact failure this piece exists to avoid, which is why variance defaults
// to a real spread rather than a token one.
//
// SENESCENCE falls out of the same lattice for free: a cell's claim time is
// stamped once and not refreshed, so the cells nearest a thallus's own
// nucleation point are always its oldest. Aging a cell out past its thallus's
// senescence limit therefore clears centre-first, outward, automatically —
// no separate "shrink from the middle" pass is needed. A cleared cell is
// bare substrate again; the neighbour whose front already stalled against it
// resumes growing into it on the very next step. When a thallus's last cell
// clears, its slot goes quiet for a staggered cooldown and then nucleates
// again elsewhere, so the war never actually finishes — it only ever pauses
// between shifts, which is the whole point of a 20-simulated-minute prewarm:
// the frame you land on already has one or two turnovers baked into it.
//
// A contested cell that reverts to substrate — either by senescence or by
// being overrun — leaves a mark in a separate, slowly-decaying "seam" field.
// Rendered as a hairline lattice of --border, it is the pane's memory of
// where a border used to run even after both sides that drew it are gone.
//
// RENDER is plain 2D canvas, no WebGL. The occupancy lattice is coarse
// (quarter the density of anything a viewer could resolve as a grid); its
// blocky per-cell ownership is smoothed into an organic contour with a real
// marching-squares pass over the lattice (binary field per species, a single
// segment per 2-crossing square, the diagonal pair for the 4-crossing saddle
// case) rather than any blur or upscale trick. Fill is per-species ordered
// dithering between --background and --ns-muted — 12 fixed densities, one
// per lattice slot, so a slot keeps a recognisable "species" texture across
// however many generations nucleate into it. Every colour is read from
// getComputedStyle at mount and on a documentElement class mutation; nothing
// is a literal. --ns-accent never appears — this is idle rock, nothing here
// is being interacted with.
// ---------------------------------------------------------------------------

export interface ThallusSiegeProps {
  /** Concurrent lichen thalli contesting the pane. @default 12 */
  count?: number;
  /**
   * Spread of growth rate sampled per thallus — the mechanism's governing
   * scalar. 0 flattens every front to the same pace, the 1.3x reconquest
   * band is never crossed, and the mosaic settles into fair, static,
   * near-Voronoi cells: the exact failure this piece is built to avoid.
   * @default 1
   */
  variance?: number;
  /** Simulation-time multiplier. @default 1 */
  speed?: number;
  /** Freezes the pane on its current developed frame without unmounting. */
  paused?: boolean;
  /** Rendered over the pane — eyebrow, headline, CTA. */
  children?: React.ReactNode;
  className?: string;
  style?: React.CSSProperties;
}

const TAU = Math.PI * 2;

// Lattice + spoke geometry.
const CELL = 6; // css px per occupancy cell — coarse on purpose
const SPOKES = 180;

// Growth mechanics.
const BASE_RATE = 0.85; // px/s at variance's centre — deliberately glacial
const CONTACT_RATIO = 1.3; // dead-band: a spoke stalls below this rate ratio
const NOISE_AMOUNT = 0.3; // fraction of rate the margin noise can add/remove

// Lifecycle timing, all in SIMULATED seconds.
const T_SEN_BASE = 780; // ~13 sim-minutes before a thallus's oldest cells fade
const T_SEN_SPREAD = 0.4; // +/- fraction of variety across individuals
const SEAM_TAU = 220; // ghost-seam decay time constant
const STAGGER_WINDOW = 300; // initial dozen nucleate across this span
const COOLDOWN_MIN = 40; // shortest gap before a retired slot tries again
const COOLDOWN_SPREAD = 140;
const NUCLEATION_CLEARANCE = CELL * 2.5;

// The pane arrives already having fought over itself for a while.
const PREWARM_SECONDS = 1200; // 20 simulated minutes
const PREWARM_DT = 2; // sim seconds per prewarm step -> 600 steps

const DRAW_INTERVAL_MS = 380; // growth is glacial; redraw far under 60fps

// 8x8 ordered (Bayer) dither matrix, values 0..63.
const BAYER8: number[][] = [
  [0, 32, 8, 40, 2, 34, 10, 42],
  [48, 16, 56, 24, 50, 18, 58, 26],
  [12, 44, 4, 36, 14, 46, 6, 38],
  [60, 28, 52, 20, 62, 30, 54, 22],
  [3, 35, 11, 43, 1, 33, 9, 41],
  [51, 19, 59, 27, 49, 17, 57, 25],
  [15, 47, 7, 39, 13, 45, 5, 37],
  [63, 31, 55, 23, 61, 29, 53, 21],
];

type Thallus = {
  id: number;
  slot: number;
  cx: number;
  cy: number;
  rate: number;
  seed: number;
  senLimit: number;
  spokeR: Float32Array;
  bboxMinC: number;
  bboxMaxC: number;
  bboxMinR: number;
  bboxMaxR: number;
  cellCount: number;
};

/** Slow, lobed angular perturbation. Not true curl noise — a stand-in built
 * from a few incommensurate sine terms in theta with a slow phase drift in
 * t, tuned so the margin scallops rather than either vanishing flat or
 * spiking into fingers. */
function marginNoise(theta: number, simTime: number, seed: number): number {
  const a = Math.sin(theta * 3 + seed * 7.1 + simTime * 0.013);
  const b = Math.sin(theta * 5 - seed * 3.3 + simTime * 0.021) * 0.6;
  const c = Math.sin(theta * 8 + seed * 11.7 - simTime * 0.009) * 0.35;
  return (a + b + c) / 1.95;
}

export function ThallusSiege({
  count = 12,
  variance = 1,
  speed = 1,
  paused = false,
  children,
  className = "",
  style,
}: ThallusSiegeProps) {
  const wrapRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  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 nSlots = Math.max(4, Math.min(24, Math.round(count)));
    const varAmt = Math.max(0, variance);
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduced = mq.matches;

    let disposed = false;
    let cssW = 0;
    let cssH = 0;
    let dpr = 1;
    let cols = 0;
    let rows = 0;
    let sized = false;
    let staticMode = false;

    // -- occupancy lattice -----------------------------------------------
    let owner: Uint16Array = new Uint16Array(0);
    let claimedAt: Float32Array = new Float32Array(0);
    let seam: Float32Array = new Float32Array(0);
    const seamCells = new Set<number>();

    let simTime = 0;
    let idSeq = 1;
    let rngState = 0x9e3779b9 >>> 0;
    const rng = () => {
      rngState ^= rngState << 13;
      rngState >>>= 0;
      rngState ^= rngState >>> 17;
      rngState ^= rngState << 5;
      rngState >>>= 0;
      return rngState / 4294967296;
    };

    const slots: (Thallus | null)[] = new Array(nSlots).fill(null);
    const slotCooldown = new Float32Array(nSlots);
    const activeById = new Map<number, Thallus>();

    // -- colors -------------------------------------------------------------
    let bgColor = "#ffffff";
    let mutedColor = "#4d4d4d";
    let borderColor = "#ebebeb";
    const readColors = () => {
      const cs = getComputedStyle(document.documentElement);
      bgColor = (cs.getPropertyValue("--background") || "#ffffff").trim() || "#ffffff";
      mutedColor = (cs.getPropertyValue("--ns-muted") || "#4d4d4d").trim() || "#4d4d4d";
      borderColor = (cs.getPropertyValue("--border") || "#ebebeb").trim() || "#ebebeb";
    };
    readColors();

    // Per-slot dithered fill, rebuilt on resize (dpr change) and theme change.
    let patterns: CanvasPattern[] = [];
    const buildPatterns = () => {
      const tileCss = 8;
      const size = Math.max(1, Math.round(tileCss * dpr));
      const next: CanvasPattern[] = [];
      for (let s = 0; s < nSlots; s++) {
        const density = nSlots > 1 ? 0.14 + (s / (nSlots - 1)) * 0.46 : 0.3;
        const off = document.createElement("canvas");
        off.width = size;
        off.height = size;
        const octx = off.getContext("2d");
        if (!octx) continue;
        for (let py = 0; py < size; py++) {
          const by = Math.min(7, Math.floor((py / size) * 8));
          for (let px = 0; px < size; px++) {
            const bx = Math.min(7, Math.floor((px / size) * 8));
            const thr = (BAYER8[by]![bx]! + 0.5) / 64;
            octx.fillStyle = thr < density ? mutedColor : bgColor;
            octx.fillRect(px, py, 1, 1);
          }
        }
        const pat = ctx.createPattern(off, "repeat");
        if (!pat) continue;
        pat.setTransform(new DOMMatrix([1 / dpr, 0, 0, 1 / dpr, 0, 0]));
        next[s] = pat;
      }
      patterns = next;
    };

    // -- lattice helpers ------------------------------------------------
    const cellIndexAt = (x: number, y: number): number => {
      const gx = Math.floor(x / CELL);
      const gy = Math.floor(y / CELL);
      if (gx < 0 || gy < 0 || gx >= cols || gy >= rows) return -1;
      return gy * cols + gx;
    };

    const clearAround = (x: number, y: number, r: number): boolean => {
      const gx = Math.floor(x / CELL);
      const gy = Math.floor(y / CELL);
      const rc = Math.ceil(r / CELL);
      for (let j = -rc; j <= rc; j++) {
        const gy2 = gy + j;
        if (gy2 < 0 || gy2 >= rows) continue;
        const row = gy2 * cols;
        for (let i = -rc; i <= rc; i++) {
          const gx2 = gx + i;
          if (gx2 < 0 || gx2 >= cols) continue;
          if (owner[row + gx2] !== 0) return false;
        }
      }
      return true;
    };

    const markSeam = (ci: number) => {
      seam[ci] = 1;
      seamCells.add(ci);
    };

    const retireThallus = (t: Thallus) => {
      activeById.delete(t.id);
      slots[t.slot] = null;
      slotCooldown[t.slot] = simTime + COOLDOWN_MIN + rng() * COOLDOWN_SPREAD;
    };

    const releaseCell = (ci: number) => {
      const prevId = owner[ci];
      if (prevId === 0) return;
      const t = activeById.get(prevId);
      if (t) {
        t.cellCount--;
        if (t.cellCount <= 0) retireThallus(t);
      }
      owner[ci] = 0;
    };

    const updateBBox = (t: Thallus, ci: number) => {
      const gx = ci % cols;
      const gy = (ci / cols) | 0;
      if (gx < t.bboxMinC) t.bboxMinC = gx;
      if (gx > t.bboxMaxC) t.bboxMaxC = gx;
      if (gy < t.bboxMinR) t.bboxMinR = gy;
      if (gy > t.bboxMaxR) t.bboxMaxR = gy;
    };

    const claimCell = (ci: number, t: Thallus, now: number) => {
      const prevId = owner[ci];
      if (prevId === t.id) return;
      if (prevId !== 0) {
        const rival = activeById.get(prevId);
        if (rival) {
          rival.cellCount--;
          if (rival.cellCount <= 0) retireThallus(rival);
        }
        markSeam(ci);
      }
      owner[ci] = t.id;
      claimedAt[ci] = now;
      t.cellCount++;
      updateBBox(t, ci);
    };

    // -- nucleation -------------------------------------------------------
    const nucleate = (slot: number, now: number) => {
      for (let attempt = 0; attempt < 10; attempt++) {
        const x = rng() * cssW;
        const y = rng() * cssH;
        if (clearAround(x, y, NUCLEATION_CLEARANCE)) {
          const id = idSeq++;
          const rate = BASE_RATE * Math.pow(2, (rng() * 2 - 1) * varAmt);
          const t: Thallus = {
            id,
            slot,
            cx: x,
            cy: y,
            rate,
            seed: rng() * 1000,
            senLimit: T_SEN_BASE * (1 - T_SEN_SPREAD / 2 + rng() * T_SEN_SPREAD),
            spokeR: new Float32Array(SPOKES),
            bboxMinC: Infinity,
            bboxMaxC: -Infinity,
            bboxMinR: Infinity,
            bboxMaxR: -Infinity,
            cellCount: 0,
          };
          slots[slot] = t;
          activeById.set(id, t);
          return;
        }
      }
      slotCooldown[slot] = now + 5; // no room yet; try again soon
    };

    const growThallus = (t: Thallus, dt: number) => {
      for (let k = 0; k < SPOKES; k++) {
        const theta = (k * TAU) / SPOKES;
        const n = marginNoise(theta, simTime, t.seed);
        const vel = t.rate * Math.max(0.15, 1 + NOISE_AMOUNT * n);
        const candR = t.spokeR[k]! + vel * dt;
        const cos = Math.cos(theta);
        const sin = Math.sin(theta);
        const ci = cellIndexAt(t.cx + cos * candR, t.cy + sin * candR);
        if (ci < 0) continue; // spoke stalls at the pane's edge
        const own = owner[ci];
        if (own === 0 || own === t.id) {
          claimCell(ci, t, simTime);
          t.spokeR[k] = candR;
          continue;
        }
        const rival = activeById.get(own);
        if (!rival) {
          claimCell(ci, t, simTime);
          t.spokeR[k] = candR;
          continue;
        }
        const ratio = t.rate / rival.rate;
        if (ratio > CONTACT_RATIO) {
          const advVel = (ratio - CONTACT_RATIO) * BASE_RATE;
          const advR = t.spokeR[k]! + advVel * dt;
          const ci2 = cellIndexAt(t.cx + cos * advR, t.cy + sin * advR);
          if (ci2 >= 0) claimCell(ci2, t, simTime);
          t.spokeR[k] = advR;
        }
        // else: rate ratio inside the dead band — this spoke stalls here.
      }
    };

    const neighborDiffers = (gx: number, gy: number, ownBefore: number): boolean => {
      const dirs: Array<[number, number]> = [
        [1, 0],
        [-1, 0],
        [0, 1],
        [0, -1],
      ];
      for (const [dx, dy] of dirs) {
        const nx = gx + dx;
        const ny = gy + dy;
        if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
        const no = owner[ny * cols + nx];
        if (no !== 0 && no !== ownBefore) return true;
      }
      return false;
    };

    const senescenceSweep = () => {
      const living = Array.from(activeById.values());
      for (const t of living) {
        if (!activeById.has(t.id)) continue; // retired earlier this sweep
        const c0 = Math.max(0, Math.floor(t.bboxMinC));
        const c1 = Math.min(cols - 1, Math.floor(t.bboxMaxC));
        const r0 = Math.max(0, Math.floor(t.bboxMinR));
        const r1 = Math.min(rows - 1, Math.floor(t.bboxMaxR));
        for (let gy = r0; gy <= r1; gy++) {
          const row = gy * cols;
          for (let gx = c0; gx <= c1; gx++) {
            const ci = row + gx;
            if (owner[ci] !== t.id) continue;
            const age = simTime - claimedAt[ci]!;
            if (age > t.senLimit) {
              if (neighborDiffers(gx, gy, t.id)) markSeam(ci);
              releaseCell(ci);
            }
          }
        }
      }
    };

    const decaySeams = (dt: number) => {
      if (seamCells.size === 0) return;
      const decay = Math.exp(-dt / SEAM_TAU);
      for (const ci of seamCells) {
        const v = seam[ci]! * decay;
        seam[ci] = v;
        if (v < 0.02) seamCells.delete(ci);
      }
    };

    const stepSim = (dt: number) => {
      if (dt <= 0) return;
      simTime += dt;
      for (let s = 0; s < nSlots; s++) {
        if (!slots[s] && simTime >= slotCooldown[s]!) nucleate(s, simTime);
      }
      for (let s = 0; s < nSlots; s++) {
        const t = slots[s];
        if (t) growThallus(t, dt);
      }
      senescenceSweep();
      decaySeams(dt);
    };

    const resetSim = () => {
      cols = Math.max(1, Math.ceil(cssW / CELL));
      rows = Math.max(1, Math.ceil(cssH / CELL));
      owner = new Uint16Array(cols * rows);
      claimedAt = new Float32Array(cols * rows);
      seam = new Float32Array(cols * rows);
      seamCells.clear();
      simTime = 0;
      idSeq = 1;
      rngState = 0x9e3779b9 >>> 0;
      activeById.clear();
      for (let s = 0; s < nSlots; s++) {
        slots[s] = null;
        slotCooldown[s] = rng() * STAGGER_WINDOW;
      }
    };

    const prewarm = () => {
      const steps = Math.round(PREWARM_SECONDS / PREWARM_DT);
      for (let i = 0; i < steps; i++) stepSim(PREWARM_DT);
    };

    // -- marching squares over the binary field for one species ----------
    const marchSpecies = (t: Thallus, path: Path2D) => {
      if (t.cellCount <= 0) return;
      const i0 = Math.max(0, Math.floor(t.bboxMinC) - 1);
      const i1 = Math.min(cols - 2, Math.floor(t.bboxMaxC));
      const j0 = Math.max(0, Math.floor(t.bboxMinR) - 1);
      const j1 = Math.min(rows - 2, Math.floor(t.bboxMaxR));
      if (i1 < i0 || j1 < j0) return;
      const sampleAt = (gx: number, gy: number): number => {
        if (gx < 0 || gy < 0 || gx >= cols || gy >= rows) return 0;
        return owner[gy * cols + gx] === t.id ? 1 : 0;
      };
      for (let j = j0; j <= j1; j++) {
        for (let i = i0; i <= i1; i++) {
          const tl = sampleAt(i, j);
          const tr = sampleAt(i + 1, j);
          const br = sampleAt(i + 1, j + 1);
          const bl = sampleAt(i, j + 1);
          if (tl === tr && tr === br && br === bl) continue;
          const x0 = i * CELL;
          const x1 = x0 + CELL;
          const y0 = j * CELL;
          const y1 = y0 + CELL;
          const midX = (x0 + x1) / 2;
          const midY = (y0 + y1) / 2;
          const T: [number, number] = [midX, y0];
          const R: [number, number] = [x1, midY];
          const B: [number, number] = [midX, y1];
          const L: [number, number] = [x0, midY];
          const crossTop = tl !== tr;
          const crossRight = tr !== br;
          const crossBottom = bl !== br;
          const crossLeft = tl !== bl;
          const count =
            (crossTop ? 1 : 0) + (crossRight ? 1 : 0) + (crossBottom ? 1 : 0) + (crossLeft ? 1 : 0);
          if (count === 2) {
            const pts: Array<[number, number]> = [];
            if (crossTop) pts.push(T);
            if (crossRight) pts.push(R);
            if (crossBottom) pts.push(B);
            if (crossLeft) pts.push(L);
            path.moveTo(pts[0]![0], pts[0]![1]);
            path.lineTo(pts[1]![0], pts[1]![1]);
          } else if (count === 4) {
            if (tl === 1) {
              path.moveTo(T[0], T[1]);
              path.lineTo(L[0], L[1]);
              path.moveTo(B[0], B[1]);
              path.lineTo(R[0], R[1]);
            } else {
              path.moveTo(T[0], T[1]);
              path.lineTo(R[0], R[1]);
              path.moveTo(B[0], B[1]);
              path.lineTo(L[0], L[1]);
            }
          }
        }
      }
    };

    const fillThallus = (t: Thallus) => {
      if (t.cellCount <= 0) return;
      const pat = patterns[t.slot];
      if (!pat) return;
      const c0 = Math.max(0, Math.floor(t.bboxMinC));
      const c1 = Math.min(cols - 1, Math.floor(t.bboxMaxC));
      const r0 = Math.max(0, Math.floor(t.bboxMinR));
      const r1 = Math.min(rows - 1, Math.floor(t.bboxMaxR));
      const path = new Path2D();
      let any = false;
      for (let gy = r0; gy <= r1; gy++) {
        const row = gy * cols;
        for (let gx = c0; gx <= c1; gx++) {
          if (owner[row + gx] === t.id) {
            path.rect(gx * CELL, gy * CELL, CELL, CELL);
            any = true;
          }
        }
      }
      if (!any) return;
      ctx.fillStyle = pat;
      ctx.fill(path);
    };

    const SEAM_BUCKET_T = [0.25, 0.5, 0.75, 1.01];
    const SEAM_BUCKET_ALPHA = [0.12, 0.22, 0.32, 0.42];

    const drawSeams = () => {
      if (seamCells.size === 0) return;
      const buckets = [new Path2D(), new Path2D(), new Path2D(), new Path2D()];
      for (const ci of seamCells) {
        const v = seam[ci]!;
        if (v < 0.02) continue;
        let b = 0;
        while (b < 3 && v > SEAM_BUCKET_T[b]!) b++;
        const gx = ci % cols;
        const gy = (ci / cols) | 0;
        buckets[b]!.rect(gx * CELL + 1, gy * CELL + 1, CELL - 2, CELL - 2);
      }
      ctx.lineWidth = 1;
      ctx.strokeStyle = borderColor;
      for (let b = 0; b < 4; b++) {
        ctx.globalAlpha = SEAM_BUCKET_ALPHA[b]!;
        ctx.stroke(buckets[b]!);
      }
      ctx.globalAlpha = 1;
    };

    const draw = () => {
      if (!sized) return;
      ctx.clearRect(0, 0, cssW, cssH);
      for (const t of activeById.values()) fillThallus(t);
      const boundary = new Path2D();
      for (const t of activeById.values()) marchSpecies(t, boundary);
      ctx.lineWidth = 1;
      ctx.strokeStyle = borderColor;
      ctx.globalAlpha = 0.55;
      ctx.stroke(boundary);
      ctx.globalAlpha = 1;
      drawSeams();
    };

    // -- host plumbing ------------------------------------------------------
    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));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };

    const rebuild = () => {
      if (cssW < 2 || cssH < 2) {
        sized = false;
        return;
      }
      applyBacking();
      buildPatterns();
      resetSim();
      prewarm();
      sized = true;
      draw();
    };

    let lastW = 0;
    let lastH = 0;
    const resize = () => {
      const rect = wrap.getBoundingClientRect();
      if (rect.width < 2 || rect.height < 2) return;
      if (Math.abs(rect.width - lastW) < 2 && Math.abs(rect.height - lastH) < 2) return;
      lastW = rect.width;
      lastH = rect.height;
      cssW = rect.width;
      cssH = rect.height;
      rebuild();
    };

    let raf = 0;
    let running = false;
    let lastMs = 0;
    let lastDrawMs = 0;

    const loop = (now: number) => {
      const dt = lastMs ? Math.min(0.25, (now - lastMs) / 1000) : 1 / 60;
      lastMs = now;
      stepSim(dt * Math.max(0, speed));
      if (now - lastDrawMs >= DRAW_INTERVAL_MS) {
        draw();
        lastDrawMs = now;
      }
      raf = requestAnimationFrame(loop);
    };

    const wake = () => {
      if (running || disposed || !sized || staticMode) return;
      running = true;
      lastMs = 0;
      raf = requestAnimationFrame(loop);
    };
    const sleep = () => {
      cancelAnimationFrame(raf);
      running = false;
    };

    const applyMode = () => {
      staticMode = reduced || pausedRef.current;
      if (staticMode) {
        sleep();
        draw();
      } else {
        wake();
      }
    };

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

    let onScreen = true;
    const io = new IntersectionObserver(
      (entries) => {
        onScreen = entries.some((en) => en.isIntersecting);
        if (!onScreen) sleep();
        else if (!staticMode && !document.hidden) wake();
      },
      { threshold: 0 }
    );
    io.observe(wrap);

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

    const onMq = () => {
      reduced = mq.matches;
      applyMode();
    };
    mq.addEventListener("change", onMq);

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

    // paused is polled rather than an effect dependency, so toggling it does
    // not tear down and re-prewarm 20 minutes of simulated history.
    let lastPolledPaused = pausedRef.current;
    let pollTimer = 0;
    const poll = () => {
      if (pausedRef.current !== lastPolledPaused) {
        lastPolledPaused = pausedRef.current;
        applyMode();
      }
      pollTimer = window.setTimeout(poll, 160);
    };
    poll();

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

  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" />
      {children ? <div className="relative z-[1] h-full w-full">{children}</div> : null}
    </div>
  );
}

ThallusSiege.displayName = "ThallusSiege";
Build spec

Build <ThallusSiege count? variance? speed? paused? children? className? style?> as a full-bleed 2D canvas pane of crustose lichen fighting a slow, never-finished territorial war. TERRITORY IS GROWN, NOT PARTITIONED — the mechanism that must never regress into a Voronoi or distance-field lookup. A dozen (count, default 12) thalli occupy independent slots and nucleate at staggered simulated times on bare substrate. Each grows a front of ~180 radial spokes; every spoke's radius advances at that thallus's own inherited rate scaled by 1 + 0.3*marginNoise(theta, simTime), a slow multi-term sine perturbation in angle and time that gives the margin lobes instead of a computed circle. A spoke's target cell is looked up in a shared occupancy lattice (coarse: 6px cells, roughly a quarter the density anything on screen could resolve) storing an owner id and a claim time. Empty or self-owned cells are simply claimed. A cell owned by a DIFFERENT thallus stalls the spoke UNLESS this thallus's rate exceeds the rival's by more than 1.3x (CONTACT_RATIO), in which case the spoke keeps advancing at (ratio - 1.3) * baseRate — real, slow reconquest of the rival's ground, not a redraw of a boundary. The 1.3x dead-band exists so the mosaic can visually settle between reconquests instead of every border crawling forever. GOVERNING SCALAR: growth-rate variance, exposed as the `variance` prop (default 1) and sampled per thallus as baseRate * 2^((rng()*2-1)*variance). At variance = 0 every thallus grows at an identical pace, the 1.3x band is never crossed, no front ever reconquers another, and the mosaic settles into flat, static, roughly-equidistant cells — a fair near-Voronoi partition in every respect except how it was computed. That is the literal failure state this piece exists to avoid, which is why the default carries a real spread (rates commonly span roughly 0.5x-2x of each other). SENESCENCE falls out of the same lattice for free: a cell's claim time is written once and never refreshed, so the cells nearest a thallus's own nucleation point are always its oldest. Each thallus carries a senescence limit (~13 simulated minutes, +/-40% per individual); any of its cells whose age exceeds that limit clears back to substrate, which happens centre-first and spreads outward automatically as more time passes, with no separate shrink pass required. A cleared cell is bare rock again, and the neighbour whose front already stalled against it resumes growing into it the very next step — reclaiming ground rather than a new partition being recomputed. When a thallus's last cell clears, its slot goes silent for a staggered cooldown (40-180 simulated seconds) and then nucleates again elsewhere with a freshly sampled rate, so the war only ever pauses between shifts. RETIRED SEAMS: a cell that reverts to substrate while a differently-owned neighbour is still alive stamps a separate, slowly decaying (per-cell, ~220s time constant) seam field; wherever that field is still non-trivial the render adds a faint inset hairline stroke of --border, so the pane keeps a visible memory of where a border used to run after both sides that drew it are gone. PREWARM: at mount (and on every resize) the simulation is fast-forwarded 1200 simulated seconds (20 minutes) at 2s steps before the first paint, so the first frame is already a developed mosaic with at least one senescence-and-reclaim cycle baked in, not an empty pane proving its concept via autoplay. RENDER: the lattice's blocky per-cell ownership is smoothed into an organic contour with a genuine marching-squares pass — a binary occupied/not field per species, one segment for a normal 2-edge-crossing square, the diagonal-consistent pair of segments for the 4-crossing saddle case — stroked in --border at low alpha; no blur or upscale trick stands in for it. Fill is per-species ordered (8x8 Bayer) dithering between --background and --ns-muted, with 12 fixed dither densities assigned one per lattice slot (not per generation), so a slot keeps a recognizable texture across however many thalli nucleate into it over the piece's lifetime. Colors are read via getComputedStyle on mount and re-read on a documentElement class MutationObserver so both themes render correctly; --ns-accent never appears anywhere in the simulation or render, since nothing here is ever being interacted with. HOST: ResizeObserver rebuilds the lattice and reruns the full prewarm on a real size change; IntersectionObserver and visibilitychange stop the single rAF loop off-screen; redraws are throttled to roughly 2.6/s since the growth itself is glacial and a 60fps redraw of an unchanging frame would be pure waste; paused is polled rather than an effect dependency so toggling it does not discard 20 minutes of simulated history. prefers-reduced-motion (and paused) skip the loop entirely and paint the prewarmed mosaic once — the component's best single frame is the resting state, not a fallback. The canvas is aria-hidden and fully non-interactive (no autoplay descriptor: the stillness is the point, motion here rewards a second visit rather than demonstrating itself on the first); children render over the pane behind a bg-background/75 + backdrop-blur scrim.

Props

PropTypeDefaultDescription
count?number12Concurrent lichen thalli contesting the pane. @default 12
variance?number1Spread of growth rate sampled per thallus — the mechanism's governing scalar. 0 flattens every front to the same pace, the 1.3x reconquest band is never crossed, and the mosaic settles into fair, static, near-Voronoi cells: the exact failure this piece is built to avoid. @default 1
speed?number1Simulation-time multiplier. @default 1
paused?booleanfalseFreezes the pane on its current developed frame without unmounting.
children?React.ReactNodeRendered over the pane — eyebrow, headline, CTA.
className?string
style?React.CSSProperties