Skip to content

ASCII Koi Pond

Shaded ASCII koi seen from above leave wakes and race for food you drop.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/ascii-koi-pond.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/color.ts
    export type Vec3 = [r: number, g: number, b: number];
    
    /**
     * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels.
     * Anything else falls back to black, so a typo never crashes a shader.
     */
    export function hexToRgb(hex: string): Vec3 {
      let value = hex.trim().replace(/^#/, "");
      if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join("");
      if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0];
      const n = Number.parseInt(value.slice(0, 6), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    
    /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function hexToVec3(hex: string): Vec3 {
      const [r, g, b] = hexToRgb(hex);
      return [r / 255, g / 255, b / 255];
    }
    
    /**
     * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as
     * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables
     * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result
     * instead of calling this every frame. Browser only; unreadable values fall back to black.
     */
    export function cssColorToRgb(color: string, element: Element): Vec3 {
      const value = color.trim();
      if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value);
      // Children of a canvas still get computed styles, but a regular parent is the safer host.
      const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element;
      const probe = document.createElement("span");
      probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden";
      probe.style.color = value;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes.
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0];
      ctx.fillStyle = "#000";
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b];
    }
    
    /**
     * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or
     * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme.
     * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe.
     */
    export function observeTheme(onChange: () => void): () => void {
      const observer = new MutationObserver(onChange);
      const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
      observer.observe(document.documentElement, options);
      if (document.body) observer.observe(document.body, options);
      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      scheme.addEventListener("change", onChange);
      return () => {
        observer.disconnect();
        scheme.removeEventListener("change", onChange);
      };
    }
    
    /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function cssColorToVec3(color: string, element: Element): Vec3 {
      const [r, g, b] = cssColorToRgb(color, element);
      return [r / 255, g / 255, b / 255];
    }
    
    const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
    const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
    
    /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */
    export function rgbToOklch([r, g, b]: Vec3): Vec3 {
      const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
      const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
      const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
      const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
      const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
      const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
      const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
      return [L, Math.hypot(A, B), Math.atan2(B, A)];
    }
    
    /** OKLCH back to 0–255 sRGB, clamped into gamut. */
    export function oklchToRgb([L, C, h]: Vec3): Vec3 {
      const A = C * Math.cos(h);
      const B = C * Math.sin(h);
      const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
      const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
      const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
      const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
      return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3;
    }
    
    /**
     * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep
     * their chroma instead of turning muddy the way an RGB mix does.
     */
    export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 {
      const [L1, C1, h1] = rgbToOklch(a);
      const [L2, C2, h2] = rgbToOklch(b);
      // A near-gray end has no meaningful hue: borrow the other end's.
      const ha = C1 < 0.02 ? h2 : h1;
      const hb = C2 < 0.02 ? h1 : h2;
      let dh = hb - ha;
      if (dh > Math.PI) dh -= Math.PI * 2;
      if (dh < -Math.PI) dh += Math.PI * 2;
      return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]);
    }
    lib/glyph/ascii-life.ts
    /**
     * Building blocks for ASCII creatures on a canvas: a grid of glyph cells (taller than wide, like
     * monospace glyphs), a diffusing wake field, follow-the-leader spines with a bend limit and a
     * travelling body wave, nearest-point queries in body coordinates, and a cell buffer that is drawn
     * in two batched passes (shaded blocks grouped by color, then glyphs blitted from a sprite atlas).
     *
     * The approach (shaded blocks under density-matched glyphs, supersampled silhouettes, a wake field
     * that lights the water, glyphs keyed to body coordinates) is learned from ascii-sharks by drewdi,
     * https://github.com/stackedlol/ascii-sharks (MIT). This file is an independent implementation.
     */
    import type { GlyphAtlas } from "@/lib/glyph/glyph-atlas";
    
    /* ------------------------------------------------------------------ grid */
    
    export interface CellGrid {
      /** Cell width and height in CSS pixels. */
      cw: number;
      ch: number;
      cols: number;
      rows: number;
    }
    
    /** A grid of cells `cell` wide and `cell × aspect` tall covering width × height. */
    export function cellGrid(width: number, height: number, cell: number, aspect = 1.45): CellGrid {
      const cw = Math.max(4, cell);
      const ch = Math.max(4, Math.round(cw * aspect));
      return { cw, ch, cols: Math.max(1, Math.ceil(width / cw)), rows: Math.max(1, Math.ceil(height / ch)) };
    }
    
    export const cellAt = (g: CellGrid, x: number, y: number) => {
      const c = Math.floor(x / g.cw);
      const r = Math.floor(y / g.ch);
      return c < 0 || r < 0 || c >= g.cols || r >= g.rows ? -1 : r * g.cols + c;
    };
    
    /* ------------------------------------------------------------------ hashing */
    
    /** Integer hash → [0, 1). Stable, so textures keyed to it don't flicker. */
    export function hash3(x: number, y: number, z: number) {
      let h = Math.imul(x | 0, 0x2c1b3c6d) ^ Math.imul(y | 0, 0x297a2d39) ^ Math.imul(z | 0, 0x7feb352d);
      h = Math.imul(h ^ (h >>> 15), 0x846ca68b);
      h ^= h >>> 16;
      return (h >>> 0) / 4294967296;
    }
    
    /* ------------------------------------------------------------------ wake field */
    
    /**
     * Energy per cell that things deposit as they swim. Each step it blurs into its neighbors and
     * decays, so wakes spread like ripples and fade.
     */
    export class WakeField {
      energy: Float32Array;
      private tmp: Float32Array;
    
      constructor(
        readonly cols: number,
        readonly rows: number,
      ) {
        this.energy = new Float32Array(cols * rows);
        this.tmp = new Float32Array(cols * rows);
      }
    
      add(index: number, amount: number, max = 1.6) {
        if (index >= 0) this.energy[index] = Math.min(max, this.energy[index] + amount);
      }
    
      /** A soft disc of energy around (x, y), `radius` cells wide (rows count wider, cells are tall). */
      stamp(g: CellGrid, x: number, y: number, radius: number, amount: number) {
        const c0 = Math.floor(x / g.cw);
        const r0 = Math.floor(y / g.ch);
        const rr = Math.ceil(radius / 1.4);
        for (let r = r0 - rr; r <= r0 + rr; r++) {
          for (let c = c0 - radius; c <= c0 + radius; c++) {
            if (c < 0 || r < 0 || c >= this.cols || r >= this.rows) continue;
            const d = Math.hypot(c - c0, (r - r0) * 1.4);
            if (d <= radius + 0.5) this.add(r * this.cols + c, amount * (1 - d / (radius + 1)));
          }
        }
      }
    
      /** A thin ring of energy, for splashes and dropped food. */
      ring(g: CellGrid, x: number, y: number, radiusPx: number, amount: number) {
        const steps = Math.max(8, Math.ceil((radiusPx * Math.PI * 2) / g.cw));
        for (let i = 0; i < steps; i++) {
          const a = (i / steps) * Math.PI * 2;
          this.add(cellAt(g, x + Math.cos(a) * radiusPx, y + Math.sin(a) * radiusPx * 0.9), amount);
        }
      }
    
      step(dt: number, decayPerSecond = 1.5) {
        const { cols, rows, energy: e, tmp } = this;
        const decay = Math.exp(-dt * decayPerSecond);
        for (let r = 0; r < rows; r++) {
          for (let c = 0; c < cols; c++) {
            const i = r * cols + c;
            const l = c > 0 ? e[i - 1] : e[i];
            const rt = c < cols - 1 ? e[i + 1] : e[i];
            const u = r > 0 ? e[i - cols] : e[i];
            const d = r < rows - 1 ? e[i + cols] : e[i];
            tmp[i] = (e[i] * 4 + l + rt + u + d) * 0.125 * decay;
          }
        }
        this.tmp = e;
        this.energy = tmp;
      }
    }
    
    /* ------------------------------------------------------------------ spines */
    
    const angleDiff = (a: number, b: number) => {
      let d = (b - a) % (Math.PI * 2);
      if (d > Math.PI) d -= Math.PI * 2;
      if (d < -Math.PI) d += Math.PI * 2;
      return d;
    };
    
    /**
     * Pulls every node after the head to `segment` px behind the one before it, bending at most
     * `maxBend` radians per joint, so a body curls into a C but never folds back on itself.
     */
    export function followSpine(nodes: Float32Array, heading: number, segment: number, maxBend: number) {
      let prev = heading + Math.PI;
      for (let k = 1; k < nodes.length / 2; k++) {
        const px = nodes[(k - 1) * 2];
        const py = nodes[(k - 1) * 2 + 1];
        let angle = Math.atan2(nodes[k * 2 + 1] - py, nodes[k * 2] - px);
        angle = prev + Math.max(-maxBend, Math.min(maxBend, angleDiff(prev, angle)));
        nodes[k * 2] = px + Math.cos(angle) * segment;
        nodes[k * 2 + 1] = py + Math.sin(angle) * segment;
        prev = angle;
      }
    }
    
    /** A straight spine from (x, y) trailing back from `heading`. */
    export function straightSpine(count: number, x: number, y: number, heading: number, length: number) {
      const nodes = new Float32Array(count * 2);
      const seg = length / (count - 1);
      for (let k = 0; k < count; k++) {
        nodes[k * 2] = x - Math.cos(heading) * seg * k;
        nodes[k * 2 + 1] = y - Math.sin(heading) * seg * k;
      }
      return nodes;
    }
    
    /**
     * Copies `src` into `out` with a travelling sideways wave whose amplitude grows toward the tail,
     * fills `arc` with cumulative lengths and returns the total length.
     */
    export function waveSpine(src: Float32Array, out: Float32Array, arc: Float32Array, phase: number, amplitude: number, waves = 5) {
      const n = src.length / 2;
      for (let k = 0; k < n; k++) {
        const s = k / (n - 1);
        const a = Math.max(0, k - 1);
        const b = Math.min(n - 1, k + 1);
        let tx = src[a * 2] - src[b * 2];
        let ty = src[a * 2 + 1] - src[b * 2 + 1];
        const tl = Math.hypot(tx, ty) || 1;
        tx /= tl;
        ty /= tl;
        const off = amplitude * (0.1 + 0.9 * s ** 1.8) * Math.sin(phase - s * waves);
        out[k * 2] = src[k * 2] - ty * off;
        out[k * 2 + 1] = src[k * 2 + 1] + tx * off;
      }
      arc[0] = 0;
      for (let k = 1; k < n; k++) arc[k] = arc[k - 1] + Math.hypot(out[k * 2] - out[(k - 1) * 2], out[k * 2 + 1] - out[(k - 1) * 2 + 1]);
      return arc[n - 1] || 1;
    }
    
    /** Result of `nearestOnSpine`: position along (s, 0 head → 1 tail), signed offset across (v), and the side's normal. */
    export interface SpineHit {
      s: number;
      v: number;
      dist: number;
      nx: number;
      ny: number;
    }
    
    /** Nearest point on a spine polyline to (px, py), in body coordinates. */
    export function nearestOnSpine(spine: Float32Array, arc: Float32Array, total: number, px: number, py: number, hit: SpineHit) {
      const n = spine.length / 2;
      let best = Infinity;
      for (let k = 0; k < n - 1; k++) {
        const ax = spine[k * 2];
        const ay = spine[k * 2 + 1];
        const sx = spine[(k + 1) * 2] - ax;
        const sy = spine[(k + 1) * 2 + 1] - ay;
        const len2 = sx * sx + sy * sy || 1;
        const u = Math.max(0, Math.min(1, ((px - ax) * sx + (py - ay) * sy) / len2));
        const qx = ax + sx * u;
        const qy = ay + sy * u;
        const d2 = (px - qx) ** 2 + (py - qy) ** 2;
        if (d2 < best) {
          best = d2;
          const len = Math.sqrt(len2);
          const side = sx * (py - ay) - sy * (px - ax) >= 0 ? 1 : -1;
          hit.s = (arc[k] + u * len) / total;
          hit.nx = (-sy / len) * side;
          hit.ny = (sx / len) * side;
          hit.v = side;
        }
      }
      hit.dist = Math.sqrt(best);
      hit.v *= hit.dist;
      return hit;
    }
    
    /* ------------------------------------------------------------------ cell buffer */
    
    /** Sub-cell sample points (x, y pairs in cell units) for anti-aliased silhouettes. */
    export const SUBSAMPLES = [0.25, 0.28, 0.75, 0.3, 0.27, 0.74, 0.74, 0.72];
    
    /**
     * What to draw in each cell this frame: an optional solid block (an index into a list of fill
     * styles, 0 = none) and an optional glyph (atlas glyph and color, 255 = none) at an opacity level.
     */
    export class CellBuffer {
      block: Uint16Array;
      glyph: Uint8Array;
      color: Uint8Array;
      alpha: Uint8Array;
      /** Which layer last wrote the cell; nearer things overwrite farther ones. */
      depth: Float32Array;
      private order: Int32Array;
    
      constructor(readonly size: number) {
        this.block = new Uint16Array(size);
        this.glyph = new Uint8Array(size);
        this.color = new Uint8Array(size);
        this.alpha = new Uint8Array(size);
        this.depth = new Float32Array(size);
        this.order = new Int32Array(size);
        this.clear();
      }
    
      clear() {
        this.block.fill(0);
        this.glyph.fill(255);
        this.alpha.fill(255);
        this.depth.fill(-1);
      }
    
      /** Writes a cell if `depth` is at least the cell's current depth. Alpha is 0–255. */
      set(i: number, depth: number, block: number, glyph: number, color: number, alpha = 255) {
        if (i < 0 || depth < this.depth[i]) return;
        this.depth[i] = depth;
        this.block[i] = block;
        this.glyph[i] = glyph;
        this.color[i] = color;
        this.alpha[i] = alpha;
      }
    
      /**
       * Draws blocks grouped by fill style (one fillStyle change per color), then glyphs from the
       * atlas (globalAlpha changes only when the level does). The context must be in CSS pixels.
       */
      draw(ctx: CanvasRenderingContext2D, g: CellGrid, styles: readonly string[], atlas: GlyphAtlas | null) {
        const { block, glyph, color, alpha, order, size } = this;
        const counts = new Int32Array(styles.length + 1);
        for (let i = 0; i < size; i++) if (block[i]) counts[block[i]]++;
        const starts = new Int32Array(styles.length + 1);
        for (let b = 1; b <= styles.length; b++) starts[b] = starts[b - 1] + counts[b - 1];
        const cursor = starts.slice();
        for (let i = 0; i < size; i++) if (block[i]) order[cursor[block[i]]++] = i;
        for (let b = 1; b < styles.length; b++) {
          if (!counts[b]) continue;
          ctx.fillStyle = styles[b];
          for (let k = starts[b]; k < starts[b] + counts[b]; k++) {
            const i = order[k];
            ctx.fillRect((i % g.cols) * g.cw + 0.5, ((i / g.cols) | 0) * g.ch + 0.5, g.cw - 1, g.ch - 1);
          }
        }
        if (!atlas) return;
        // Atlas sprites are square (as tall as a cell), so center them in the narrower cell.
        const ox = (g.cw - atlas.cell) / 2;
        const oy = (g.ch - atlas.cell) / 2;
        let current = 255;
        for (let i = 0; i < size; i++) {
          if (glyph[i] === 255) continue;
          if (alpha[i] !== current) {
            current = alpha[i];
            ctx.globalAlpha = current / 255;
          }
          atlas.draw(ctx, glyph[i], color[i], (i % g.cols) * g.cw + ox, ((i / g.cols) | 0) * g.ch + oy);
        }
        ctx.globalAlpha = 1;
      }
    }
    
    /* ------------------------------------------------------------------ colors */
    
    /** Parses any CSS color a canvas understands into 0–255 RGB (browser only). */
    export function parseColor(color: string): [number, number, number] {
      const c = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!c) return [0, 0, 0];
      c.fillStyle = "#000";
      c.fillStyle = color;
      c.fillRect(0, 0, 1, 1);
      const [r, g, b] = c.getImageData(0, 0, 1, 1).data;
      return [r, g, b];
    }
    
    export const mix3 = (a: readonly number[], b: readonly number[], t: number) => `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)} ${Math.round(a[1] + (b[1] - a[1]) * t)} ${Math.round(a[2] + (b[2] - a[2]) * t)})`;
    lib/glyph/canvas-loop.ts
    /**
     * The render loop every glyph and dither canvas shares: sizes the canvas to its host at a capped
     * pixel ratio, runs only while the host is on screen, the tab is visible, nothing is paused and
     * the user hasn't asked for reduced motion, and otherwise keeps one up-to-date still frame.
     * Framework-free: start it in an effect and dispose it in the cleanup. Browser only.
     */
    
    export interface CanvasLoopFrame {
      /** Seconds since the previous frame (capped at 0.05), or 0 for a redraw without advancing. */
      dt: number;
      /** Seconds the loop has been running. */
      time: number;
      /** True under reduced motion: draw a calm, finished-looking frame. */
      still: boolean;
      /** Host size in CSS pixels. */
      width: number;
      height: number;
      /** Device pixel ratio of the canvas (capped). */
      dpr: number;
    }
    
    export interface CanvasLoopOptions {
      /** Element observed for size and visibility. */
      host: HTMLElement;
      /** Canvas sized to the host times the pixel ratio. */
      canvas: HTMLCanvasElement;
      /** Advance by `frame.dt` and draw. Called every animation frame while running, and with dt 0 after resizes and redraws. */
      render: (frame: CanvasLoopFrame) => void;
      /** Called after the canvas is resized, before it is redrawn. */
      resize?: (frame: CanvasLoopFrame) => void;
      /** Read before every frame; while it returns true the loop stops and the last frame stays. */
      isPaused?: () => boolean;
      /** Highest device pixel ratio used. @default 2 */
      maxDpr?: number;
      /** Margin around the viewport that still counts as on screen. @default "120px" */
      rootMargin?: string;
    }
    
    export interface CanvasLoop {
      /** Re-checks paused, visibility and reduced motion, and starts or stops the loop. Call it when props change. */
      wake(): void;
      /** Draws one frame without advancing (no-op while the loop is running, since the next frame will). */
      redraw(): void;
      /** Stops the loop and removes every observer and listener. */
      dispose(): void;
    }
    
    export function runCanvasLoop(options: CanvasLoopOptions): CanvasLoop {
      const { host, canvas, rootMargin = "120px" } = options;
      const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
      let width = 0;
      let height = 0;
      let dpr = 1;
      let raf = 0;
      let last = 0;
      let time = 0;
      let inView = false;
      let disposed = false;
    
      const frame = (dt: number): CanvasLoopFrame => ({ dt, time, still: reducedMotion.matches, width, height, dpr });
      const shouldRun = () => !disposed && inView && !document.hidden && !reducedMotion.matches && !options.isPaused?.();
    
      const tick = (now: number) => {
        raf = 0;
        if (!shouldRun()) return;
        const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
        last = now;
        time += dt;
        options.render(frame(dt));
        raf = requestAnimationFrame(tick);
      };
    
      const wake = () => {
        if (disposed) return;
        if (shouldRun()) {
          if (!raf) {
            last = 0;
            raf = requestAnimationFrame(tick);
          }
          return;
        }
        if (raf) cancelAnimationFrame(raf);
        raf = 0;
      };
    
      const redraw = () => {
        if (!disposed && !raf && width > 0) options.render(frame(0));
      };
    
      const resize = () => {
        width = Math.max(1, host.clientWidth);
        height = Math.max(1, host.clientHeight);
        dpr = Math.min(window.devicePixelRatio || 1, options.maxDpr ?? 2);
        const w = Math.max(1, Math.round(width * dpr));
        const h = Math.max(1, Math.round(height * dpr));
        if (canvas.width !== w || canvas.height !== h) {
          canvas.width = w;
          canvas.height = h;
        }
        options.resize?.(frame(0));
        // Resizing clears the canvas, so repaint right away even while running.
        options.render(frame(0));
      };
    
      const resizeObserver = new ResizeObserver(resize);
      const intersection = new IntersectionObserver(
        ([entry]) => {
          inView = entry?.isIntersecting ?? true;
          wake();
        },
        { rootMargin },
      );
      const onVisibility = () => wake();
      const onMotionChange = () => {
        wake();
        redraw();
      };
    
      resize();
      resizeObserver.observe(host);
      intersection.observe(host);
      document.addEventListener("visibilitychange", onVisibility);
      reducedMotion.addEventListener("change", onMotionChange);
    
      return {
        wake,
        redraw,
        dispose() {
          disposed = true;
          if (raf) cancelAnimationFrame(raf);
          raf = 0;
          resizeObserver.disconnect();
          intersection.disconnect();
          document.removeEventListener("visibilitychange", onVisibility);
          reducedMotion.removeEventListener("change", onMotionChange);
        },
      };
    }
    lib/glyph/glyph-atlas.ts
    /**
     * Glyph sprite atlases: every glyph of a ramp is rendered once per color into one canvas, and
     * cells are blitted from it with `drawImage`. Drawing text per cell per frame (`fillText`) is far
     * slower, so glyph canvases never do it. Browser only.
     */
    
    export interface GlyphAtlasOptions {
      /** Glyphs to pre-render, e.g. `rampGlyphs("classic")`. Spaces are kept as empty sprites. */
      glyphs: readonly string[];
      /** One tint per color, as any color a canvas accepts. Resolve CSS variables first with `resolveCanvasColors`. */
      colors: readonly string[];
      /** Size of one square cell in CSS pixels. */
      cell: number;
      /** Device pixel ratio to render for, capped at 2. @default 1 */
      dpr?: number;
      /** Font stack. @default Geist Mono (`--font-geist-mono`), then system monospace */
      fontFamily?: string;
      /** Font weight. @default 500 */
      fontWeight?: number | string;
      /** Glyph size as a share of the cell. @default 0.8 */
      fontScale?: number;
      /** Largest scale a sprite will be drawn at, so enlarged glyphs stay sharp. @default 1 */
      maxScale?: number;
    }
    
    export interface GlyphAtlas {
      readonly canvas: HTMLCanvasElement;
      readonly glyphs: readonly string[];
      readonly colors: readonly string[];
      /** Cell size in CSS pixels. */
      readonly cell: number;
      /** Index of a glyph in the atlas, or -1. */
      index(glyph: string): number;
      /**
       * Blits a glyph in a color with its cell's top-left corner at (x, y), in the context's current
       * units (CSS pixels when the context is scaled by the device pixel ratio). `scale` grows it
       * around the cell center. Set `globalAlpha` on the context for faded glyphs.
       */
      draw(ctx: CanvasRenderingContext2D, glyph: number, color: number, x: number, y: number, scale?: number): void;
    }
    
    const FALLBACK_MONO = 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Cascadia Mono", "Roboto Mono", monospace';
    const SHADES: Record<string, number> = { "░": 0.25, "▒": 0.5, "▓": 0.75, "█": 1 };
    const GUTTER = 1;
    const MAX_CACHED = 16;
    const cache = new Map<string, GlyphAtlas>();
    
    /** Geist Mono as set up by the site (`--font-geist-mono`), followed by system monospace fonts. */
    export function monoFontFamily(element: Element = document.documentElement): string {
      const geist = getComputedStyle(element).getPropertyValue("--font-geist-mono").trim();
      return geist ? `${geist}, ${FALLBACK_MONO}` : FALLBACK_MONO;
    }
    
    /**
     * Turns CSS colors a canvas can't parse (`var(--primary)`, `color-mix(…)`, `currentColor`) into
     * computed `rgb()` strings by styling a hidden probe inside `element`. Plain colors pass through.
     */
    export function resolveCanvasColors(colors: readonly string[], element: Element): string[] {
      let probe: HTMLSpanElement | null = null;
      const out = colors.map((color) => {
        if (!/var\(|color-mix\(|currentcolor/i.test(color)) return color;
        if (!probe) {
          probe = document.createElement("span");
          probe.style.display = "none";
          element.appendChild(probe);
        }
        probe.style.color = "";
        probe.style.color = color;
        return getComputedStyle(probe).color || color;
      });
      (probe as HTMLSpanElement | null)?.remove();
      return out;
    }
    
    const fontSpec = (options: GlyphAtlasOptions, size: number) =>
      `${options.fontWeight ?? 500} ${size}px ${options.fontFamily ?? FALLBACK_MONO}`;
    
    /** Resolves once the atlas font is loaded (or failed to load), so atlases built after it use the real face. */
    export async function loadGlyphFont(options: Pick<GlyphAtlasOptions, "glyphs" | "fontFamily" | "fontWeight">): Promise<void> {
      try {
        await document.fonts?.load(fontSpec({ ...options, colors: [], cell: 16 }, 16), options.glyphs.join("").replace(/\s/g, "") || "0");
      } catch {
        // A missing font falls back to the system monospace face.
      }
    }
    
    function createAtlas(options: GlyphAtlasOptions): GlyphAtlas {
      const { glyphs, colors, cell } = options;
      const dpr = Math.min(2, Math.max(1, options.dpr ?? 1));
      const sprite = Math.max(2, Math.ceil(cell * dpr * Math.max(1, options.maxScale ?? 1)));
      const stride = sprite + GUTTER * 2;
      const canvas = document.createElement("canvas");
      canvas.width = Math.max(1, glyphs.length * stride);
      canvas.height = Math.max(1, colors.length * stride);
      const ctx = canvas.getContext("2d");
      if (ctx) {
        ctx.font = fontSpec(options, Math.round(sprite * (options.fontScale ?? 0.8)));
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        colors.forEach((color, row) => {
          ctx.fillStyle = color;
          glyphs.forEach((glyph, col) => {
            const x = col * stride + GUTTER;
            const y = row * stride + GUTTER;
            const shade = SHADES[glyph];
            if (shade === 1) ctx.fillRect(x, y, sprite, sprite);
            else if (shade) {
              // Shade blocks as crisp dot screens rather than whatever the font draws.
              const dots = 4;
              const size = sprite / dots;
              for (let dy = 0; dy < dots; dy++) {
                for (let dx = 0; dx < dots; dx++) {
                  const on = shade === 0.5 ? (dx + dy) % 2 === 0 : shade === 0.25 ? dx % 2 === 0 && dy % 2 === 0 : !(dx % 2 === 1 && dy % 2 === 1);
                  if (on) ctx.fillRect(x + dx * size, y + dy * size, Math.ceil(size), Math.ceil(size));
                }
              }
            } else if (glyph.trim()) ctx.fillText(glyph, x + sprite / 2, y + sprite / 2 + sprite * 0.04);
          });
        });
      }
      const lookup = new Map(glyphs.map((g, i) => [g, i]));
      return {
        canvas,
        glyphs,
        colors,
        cell,
        index: (glyph) => lookup.get(glyph) ?? -1,
        draw(target, glyph, color, x, y, scale = 1) {
          if (glyph < 0 || color < 0 || glyph >= glyphs.length || color >= colors.length) return;
          const size = cell * scale;
          const offset = (size - cell) / 2;
          target.drawImage(canvas, glyph * stride + GUTTER, color * stride + GUTTER, sprite, sprite, x - offset, y - offset, size, size);
        },
      };
    }
    
    /**
     * Returns a glyph atlas for these options, building it on first use. Atlases are shared between
     * components with identical options. Once the font has loaded, the next call builds a fresh atlas
     * with the real face instead of reusing one drawn with the fallback.
     */
    export function getGlyphAtlas(options: GlyphAtlasOptions): GlyphAtlas {
      const dpr = Math.min(2, Math.max(1, options.dpr ?? 1));
      const fontReady = typeof document === "undefined" || !document.fonts ? true : document.fonts.check(fontSpec(options, 16));
      const key = [options.glyphs.join(""), options.colors.join("|"), options.cell, dpr, options.fontFamily, options.fontWeight, options.fontScale, options.maxScale, fontReady].join("§");
      const hit = cache.get(key);
      if (hit) {
        cache.delete(key);
        cache.set(key, hit);
        return hit;
      }
      const atlas = createAtlas({ ...options, dpr });
      cache.set(key, atlas);
      while (cache.size > MAX_CACHED) cache.delete(cache.keys().next().value as string);
      return atlas;
    }
  3. Copy the source

    components/ui/ascii-koi-pond/ascii-koi-pond.tsx
    "use client";
    
    /*
     * The rendering here (top-down bodies, shaded blocks under density-matched glyphs, sub-sampled
     * edges, a wake field that lights the water) learns from ascii-sharks by drewdi,
     * https://github.com/stackedlol/ascii-sharks (MIT). This is an independent implementation.
     */
    
    import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from "react";
    import { observeTheme } from "@/lib/color";
    import { cn } from "@/lib/utils";
    import { runCanvasLoop, type CanvasLoop } from "@/lib/glyph/canvas-loop";
    import { getGlyphAtlas, loadGlyphFont, monoFontFamily, resolveCanvasColors, type GlyphAtlas } from "@/lib/glyph/glyph-atlas";
    import { CellBuffer, cellAt, cellGrid, followSpine, hash3, mix3, nearestOnSpine, parseColor, straightSpine, SUBSAMPLES, WakeField, waveSpine, type CellGrid, type SpineHit } from "@/lib/glyph/ascii-life";
    
    export interface KoiPondColors {
      /** Water behind everything. */
      background: string;
      /** Body (the pale scales). */
      body: string;
      /** Glyphs on pale scales. */
      bodyInk: string;
      /** Bright pattern patches (orange). */
      patch: string;
      /** Deep pattern patches (red). */
      patchDeep: string;
      /** Glyphs on patches. */
      patchInk: string;
      /** Dark markings (sumi). */
      dark: string;
      /** Eyes. */
      eye: string;
      /** Wakes, ripples, lily pad outlines and the faint grid. */
      line: string;
    }
    
    export type KoiPondPalette = "light" | "dark" | (Partial<KoiPondColors> & { base?: "light" | "dark" });
    
    export interface AsciiKoiPondProps extends Omit<ComponentProps<"div">, "children"> {
      /** Content above the pond, e.g. a hero. */
      children?: ReactNode;
      /** How many koi swim in the pond (1–12). @default 5 */
      koi?: number;
      /** Cell width in px; cells are about 1.45 times as tall and glyphs scale with them. @default 8 */
      cell?: number;
      /** Built-in light or dark colors, or your own (any CSS color). An object starts from `base`. @default "light" */
      palette?: KoiPondPalette;
      /** Drifting lily pads drawn as outlines. @default 3 */
      lilyPads?: number;
      /** Koi drift toward the cursor, scatter from fast moves, and a click drops food. @default true */
      interactive?: boolean;
      /** Show how many pellets the koi have eaten in the top-right corner. @default true */
      showCounter?: boolean;
      /** Called with the running total each time a koi eats a pellet. */
      onFeed?: (total: number) => void;
      /** Freezes the pond on its current frame. @default false */
      paused?: boolean;
    }
    
    const PALETTES: Record<"light" | "dark", KoiPondColors> = {
      light: {
        background: "#f7f6f2",
        body: "#ece9e1",
        bodyInk: "#6f6a5f",
        patch: "#ef8b3c",
        patchDeep: "#d2452a",
        patchInk: "#5e1f0c",
        dark: "#34322f",
        eye: "#f7f6f2",
        line: "#b4ae9f",
      },
      dark: {
        background: "#11100f",
        body: "#b9b5ad",
        bodyInk: "#2a2825",
        patch: "#d97b2f",
        patchDeep: "#b33a20",
        patchInk: "#2a0e05",
        dark: "#23211f",
        eye: "#ece9e2",
        line: "#4a4743",
      },
    };
    
    /** Koi varieties: how much of the body carries pattern, and which patch colors. */
    const VARIETIES = [
      { patch: 0.4, deep: 0.5, sumi: 0 }, // kohaku-like: white with red-orange
      { patch: 0.34, deep: 0.25, sumi: 0.12 }, // showa-like: with black markings
      { patch: 0.6, deep: 0.15, sumi: 0 }, // mostly orange
      { patch: 0.28, deep: 0.8, sumi: 0.04 }, // pale with deep red
    ] as const;
    
    const SEGMENTS = 16;
    const SHADES = 10;
    const GLYPHS = [..." .'`,:;-=+*o%#@\\|/~"];
    const G = Object.fromEntries(GLYPHS.map((g, i) => [g, i])) as Record<string, number>;
    /** Sparse glyphs on lit scales (none on the brightest), dense ones in shadow. */
    const RAMP = [255, G["."], G[":"], G[";"], G["+"], G["*"], G["%"], G["#"], G["@"]];
    const SLOPE = [G["-"], G["\\"], G["|"], G["/"]];
    /** Atlas color slots. */
    const C = { bodyInk: 0, patchInk: 1, line: 2, eye: 3, food: 4, sumiInk: 5, fin: 6 } as const;
    
    interface Koi {
      /** Spine points, head first: [x0, y0, x1, y1, …]. */
      nodes: Float32Array;
      heading: number;
      speed: number;
      length: number;
      width: number;
      seed: number;
      variety: (typeof VARIETIES)[number];
      phase: number;
      wander: number;
    }
    
    interface Pad {
      x: number;
      y: number;
      r: number;
      vx: number;
      vy: number;
      spin: number;
      notch: number;
    }
    
    const hash = (a: number, b: number, c = 0) => hash3(a, b, c);
    
    /** Smooth value noise on a grid, 0–1. */
    function noise(x: number, y: number, seed: number) {
      const xi = Math.floor(x);
      const yi = Math.floor(y);
      const xf = x - xi;
      const yf = y - yi;
      const u = xf * xf * (3 - 2 * xf);
      const v = yf * yf * (3 - 2 * yf);
      const a = hash(xi, yi, seed);
      const b = hash(xi + 1, yi, seed);
      const c = hash(xi, yi + 1, seed);
      const d = hash(xi + 1, yi + 1, seed);
      return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
    }
    
    /** Body half-width along the spine (0 head → 1 tail tip), as a share of the full width. */
    function profile(t: number) {
      if (t < 0.1) return 0.5 + Math.sin((t / 0.1) * Math.PI * 0.5) * 0.4;
      if (t < 0.32) return 0.9 + Math.sin(((t - 0.1) / 0.22) * Math.PI * 0.5) * 0.1;
      if (t < 0.8) return 1 - ((t - 0.32) / 0.48) ** 1.3 * 0.84;
      return 0.16;
    }
    
    function resolvePalette(palette: KoiPondPalette): KoiPondColors {
      if (palette === "light" || palette === "dark") return PALETTES[palette];
      return { ...PALETTES[palette.base ?? "light"], ...palette };
    }
    
    /** A line glyph for an angle: -, \, | or /. */
    const slope = (angle: number) => SLOPE[((Math.round(((angle % Math.PI) + Math.PI) / (Math.PI / 4)) % 4) + 4) % 4];
    
    /**
     * An ASCII koi pond seen from above. Each koi is a follow-the-leader spine with a swaying tail,
     * drawn as shaded blocks (pale scales, orange and red patches, dark markings) under glyphs that
     * thicken into shadow, with soft supersampled edges, fanned fins and eyes. Koi drift toward the
     * cursor, scatter from fast moves and race for food dropped with a click; their wakes light the
     * water and fade, and lily pads drift past as outlines. Glyphs come from a shared atlas; the canvas
     * pauses offscreen and in hidden tabs and draws one still frame under reduced motion.
     */
    export function AsciiKoiPond({
      children,
      koi: koiCount = 5,
      cell = 8,
      palette = "light",
      lilyPads = 3,
      interactive = true,
      showCounter = true,
      onFeed,
      paused = false,
      className,
      style,
      ...props
    }: AsciiKoiPondProps) {
      const rootRef = useRef<HTMLDivElement>(null);
      const canvasRef = useRef<HTMLCanvasElement>(null);
      const [fed, setFed] = useState(0);
      const colors = resolvePalette(palette);
      const colorKey = JSON.stringify(colors);
      // A palette made of theme variables is re-resolved after a theme switch.
      const themed = /var\(|color-mix\(|currentcolor/i.test(colorKey);
      const [themeKey, setThemeKey] = useState(0);
      useEffect(() => (themed ? observeTheme(() => setThemeKey((k) => k + 1)) : undefined), [themed]);
      const live = useRef({ paused, interactive, onFeed, loop: null as CanvasLoop | null });
      useEffect(() => {
        live.current.paused = paused;
        live.current.interactive = interactive;
        live.current.onFeed = onFeed;
        live.current.loop?.wake();
      });
    
      useEffect(() => {
        const root = rootRef.current;
        const canvas = canvasRef.current;
        const ctx = canvas?.getContext("2d");
        if (!root || !canvas || !ctx) return;
        const state = live.current;
        const pal: KoiPondColors = JSON.parse(colorKey);
        const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
        const fontFamily = monoFontFamily(root);
        const rgb = (c: string) => parseColor(resolveCanvasColors([c], root)[0]);
        const [bgRgb, lineRgb] = [rgb(pal.background), rgb(pal.line)];
        // Fins are drawn in whichever of body or body ink stands out more from the water.
        const [bodyRgb, bodyInkRgb] = [rgb(pal.body), rgb(pal.bodyInk)];
        const contrast = (c: readonly number[]) => Math.abs(c[0] + c[1] + c[2] - bgRgb[0] - bgRgb[1] - bgRgb[2]);
        const finInk = mix3(contrast(bodyRgb) > contrast(bodyInkRgb) ? bodyRgb : bodyInkRgb, bgRgb, 0.15);
        const atlasColors = resolveCanvasColors([pal.bodyInk, pal.patchInk, pal.line, pal.eye, pal.patchDeep, pal.body, finInk], root);
    
        // Shaded block ramps per pattern layer: from a shadowed tone to a sunlit one.
        const blockStyles = [""];
        for (const base of [pal.body, pal.patch, pal.patchDeep, pal.dark]) {
          const c = rgb(base);
          const shadow = c.map((v) => v * 0.62);
          const lit = c.map((v) => v + (255 - v) * 0.22);
          for (let l = 0; l < SHADES; l++) blockStyles.push(mix3(shadow, lit, l / (SHADES - 1)));
        }
        const padStyle = blockStyles.length;
        blockStyles.push(mix3(lineRgb, bgRgb, 0.84));
        const blockIndex = (layer: number, shade: number) => 1 + layer * SHADES + Math.min(SHADES - 1, Math.max(0, Math.round(shade * (SHADES - 1))));
        const wakeStyles = [0.35, 0.5, 0.65, 0.8, 0.95].map((a) => `rgba(${lineRgb.join(",")},${a})`);
    
        let grid: CellGrid = cellGrid(1, 1, cell);
        let width = 1;
        let height = 1;
        let dpr = 1;
        let atlas: GlyphAtlas | null = null;
        let wake = new WakeField(1, 1);
        let cells = new CellBuffer(1);
        let background: HTMLCanvasElement | null = null;
        let disposed = false;
        let time = 0;
        let total = 0;
        const fish: Koi[] = [];
        const pads: Pad[] = [];
        const food: { x: number; y: number; t: number }[] = [];
        const pointer = { x: -1e4, y: -1e4, vx: 0, vy: 0, inside: false, t: 0, stamp: 0 };
        const count = Math.max(1, Math.min(12, Math.round(koiCount)));
        const hit: SpineHit = { s: 0, v: 0, dist: 0, nx: 0, ny: 0 };
        const spine = new Float32Array((SEGMENTS + 1) * 2);
        const arc = new Float32Array(SEGMENTS + 1);
    
        const buildAtlas = () => {
          atlas = getGlyphAtlas({ glyphs: GLYPHS, colors: atlasColors, cell: grid.ch, dpr, fontFamily, fontWeight: 600, fontScale: 0.74 });
        };
    
        const paintBackground = () => {
          background ??= document.createElement("canvas");
          background.width = Math.round(width * dpr);
          background.height = Math.round(height * dpr);
          const g = background.getContext("2d");
          if (!g) return;
          g.fillStyle = `rgb(${bgRgb.join(",")})`;
          g.fillRect(0, 0, background.width, background.height);
          g.strokeStyle = `rgba(${lineRgb.join(",")},0.16)`;
          g.lineWidth = 1;
          g.beginPath();
          for (let c = 0; c <= grid.cols; c++) {
            const x = Math.round(c * grid.cw * dpr) + 0.5;
            g.moveTo(x, 0);
            g.lineTo(x, background.height);
          }
          for (let r = 0; r <= grid.rows; r++) {
            const y = Math.round(r * grid.ch * dpr) + 0.5;
            g.moveTo(0, y);
            g.lineTo(background.width, y);
          }
          g.stroke();
        };
    
        const spawn = (i: number): Koi => {
          // At least ~24 cells long so the shading has room, whatever the cell size.
          const length = Math.max(grid.cw * 24, Math.min(Math.max(300, grid.cw * 32), Math.min(width, height) * 0.44)) * (0.8 + hash(i, 1) * 0.35);
          const x = width * (0.15 + hash(i, 2) * 0.7);
          const y = height * (0.15 + hash(i, 3) * 0.7);
          const heading = hash(i, 4) * Math.PI * 2;
          return {
            nodes: straightSpine(SEGMENTS + 1, x, y, heading, length),
            heading,
            speed: 24 + hash(i, 5) * 16,
            length,
            width: length * (0.22 + hash(i, 6) * 0.04),
            seed: 1000 + i * 97,
            variety: VARIETIES[Math.floor(hash(i, 7) * VARIETIES.length)],
            phase: hash(i, 8) * 10,
            wander: hash(i, 9) * Math.PI * 2,
          };
        };
    
        /* ---------------------------------------------------------- simulation */
    
        const steer = (k: Koi, dt: number) => {
          const hx = k.nodes[0];
          const hy = k.nodes[1];
          let urgency = 1.2;
          let speed = k.speed;
          k.wander += (noise(time * 0.15, k.seed * 0.01, k.seed) - 0.5) * dt * 3;
          let target = k.wander;
          // Food first, then a curious drift toward the cursor.
          let pellet: (typeof food)[number] | null = null;
          let best = Infinity;
          for (const f of food) {
            const d = Math.hypot(f.x - hx, f.y - hy);
            if (d < best) {
              best = d;
              pellet = f;
            }
          }
          if (pellet && best < Math.max(width, height)) {
            target = Math.atan2(pellet.y - hy, pellet.x - hx);
            urgency = 3.2;
            speed = k.speed * 2.1;
            if (best < k.width * 0.6) {
              food.splice(food.indexOf(pellet), 1);
              total += 1;
              wake.ring(grid, pellet.x, pellet.y, grid.cw * 2, 1.1);
              setFed(total);
              state.onFeed?.(total);
            }
          } else if (pointer.inside && state.interactive) {
            const d = Math.hypot(pointer.x - hx, pointer.y - hy);
            const fast = Math.hypot(pointer.vx, pointer.vy) > 900 && time - pointer.t < 0.15;
            if (fast && d < k.length * 1.6) {
              // Startled: dart away.
              target = Math.atan2(hy - pointer.y, hx - pointer.x);
              urgency = 5;
              speed = k.speed * 3;
            } else if (d < Math.max(width, height) * 0.45 && d > k.length * 0.5) {
              target = Math.atan2(pointer.y - hy, pointer.x - hx) + Math.sin(time * 0.7 + k.phase) * 0.6;
              urgency = 1.6;
            }
          }
          // Keep a little apart from the others.
          for (const o of fish) {
            if (o === k) continue;
            const dx = hx - o.nodes[0];
            const dy = hy - o.nodes[1];
            const d = Math.hypot(dx, dy);
            if (d > 0 && d < k.width * 2.2) target = Math.atan2(Math.sin(target) + (dy / d) * 0.8, Math.cos(target) + (dx / d) * 0.8);
          }
          // Turn back from the edges.
          const margin = k.length * 0.6;
          if (hx < -margin * 0.4 || hx > width + margin * 0.4 || hy < -margin * 0.4 || hy > height + margin * 0.4) {
            target = Math.atan2(height / 2 - hy, width / 2 - hx);
            urgency = 2.4;
          }
          let delta = target - k.heading;
          delta = Math.atan2(Math.sin(delta), Math.cos(delta));
          const v = speed * (0.85 + Math.sin(time * 2 + k.phase) * 0.15);
          // Turn no tighter than a circle about a third of the body long, so koi glide in arcs.
          const maxTurn = v / (k.length * (urgency > 3 ? 0.22 : 0.34));
          k.heading += Math.max(-maxTurn, Math.min(maxTurn, delta * urgency)) * dt;
          k.wander = k.heading + Math.atan2(Math.sin(k.wander - k.heading), Math.cos(k.wander - k.heading)) * 0.98;
          k.nodes[0] += Math.cos(k.heading) * v * dt;
          k.nodes[1] += Math.sin(k.heading) * v * dt;
          followSpine(k.nodes, k.heading, k.length / SEGMENTS, 0.13);
          // The tail sweeps the water.
          for (let s = SEGMENTS - 5; s <= SEGMENTS; s++) wake.add(cellAt(grid, k.nodes[s * 2], k.nodes[s * 2 + 1]), 0.05 + v * 0.0016);
        };
    
        const step = (dt: number) => {
          time += dt;
          for (const k of fish) steer(k, dt);
          for (const p of pads) {
            p.x += p.vx * dt;
            p.y += p.vy * dt;
            p.spin += dt * 0.05;
            if (p.x < -p.r) p.x = width + p.r;
            if (p.x > width + p.r) p.x = -p.r;
            if (p.y < -p.r) p.y = height + p.r;
            if (p.y > height + p.r) p.y = -p.r;
          }
          for (let i = food.length - 1; i >= 0; i--) if (time - food[i].t > 12) food.splice(i, 1);
          if (pointer.inside && state.interactive) wake.stamp(grid, pointer.x, pointer.y, 1, 0.05);
          wake.step(dt, 1.2);
        };
    
        /* ---------------------------------------------------------- drawing */
    
        const shadeOut = { shade: 0 };
    
        /** Classifies a point against the loaded spine: 0 miss, 1 body, 2 fin or tail, 3 dorsal ridge. */
        const sample = (k: Koi, totalLen: number, px: number, py: number) => {
          nearestOnSpine(spine, arc, totalLen, px, py, hit);
          const t = hit.s;
          const half = (profile(t) * k.width) / 2;
          if (hit.dist <= half && t < 0.8) {
            const across = hit.dist / (half || 1);
            // A rounded back lit from the top left.
            const facing = (hit.nx * -0.6 + hit.ny * -0.8) * across;
            shadeOut.shade = 0.68 - across * across * 0.44 + facing * 0.26;
            // The dorsal fin runs down the middle of the back as a thin bright ridge.
            return t > 0.3 && t < 0.62 && hit.dist < k.width * 0.07 ? 3 : 1;
          }
          // Pectoral fins sweep back from behind the head, small pelvic fins further down.
          const pec = (t - 0.17) / 0.17;
          const pel = (t - 0.5) / 0.08;
          const fin =
            (pec > 0 && pec < 1 && hit.dist < half + k.width * 0.36 * Math.sin(pec * Math.PI) * (1 - pec * 0.4)) ||
            (pel > 0 && pel < 1 && hit.dist < half + k.width * 0.14 * Math.sin(pel * Math.PI));
          // The tail fans out into a shallow fork; nothing past its tip.
          const fan = (t - 0.8) / 0.2;
          const tail = fan > 0 && t < 0.998 && hit.dist < k.width * (0.08 + 0.42 * fan) && !(fan > 0.72 && hit.dist < k.width * 0.5 * (fan - 0.72));
          if (fin || tail) {
            shadeOut.shade = 0.5;
            return 2;
          }
          return 0;
        };
    
        const drawKoi = (k: Koi, order: number) => {
          const totalLen = waveSpine(k.nodes, spine, arc, time * 6 + k.phase, k.width * 0.2, 3.2);
          let minX = Infinity;
          let maxX = -Infinity;
          let minY = Infinity;
          let maxY = -Infinity;
          for (let s = 0; s <= SEGMENTS; s++) {
            minX = Math.min(minX, spine[s * 2]);
            maxX = Math.max(maxX, spine[s * 2]);
            minY = Math.min(minY, spine[s * 2 + 1]);
            maxY = Math.max(maxY, spine[s * 2 + 1]);
          }
          const pad = k.width;
          const c0 = Math.max(0, Math.floor((minX - pad) / grid.cw));
          const c1 = Math.min(grid.cols - 1, Math.floor((maxX + pad) / grid.cw));
          const r0 = Math.max(0, Math.floor((minY - pad) / grid.ch));
          const r1 = Math.min(grid.rows - 1, Math.floor((maxY + pad) / grid.ch));
          const depth = 1 + order * 0.01;
          // Eyes: the cells on either side of the head.
          const ex = spine[2];
          const ey = spine[3];
          const dirX = spine[0] - spine[4];
          const dirY = spine[1] - spine[5];
          const dl = Math.hypot(dirX, dirY) || 1;
          const eyes = [-1, 1].map((side) => cellAt(grid, ex - (dirY / dl) * side * k.width * 0.24, ey + (dirX / dl) * side * k.width * 0.24));
          for (let r = r0; r <= r1; r++) {
            for (let c = c0; c <= c1; c++) {
              const x = c * grid.cw;
              const y = r * grid.ch;
              const flag = sample(k, totalLen, x + grid.cw / 2, y + grid.ch / 2);
              if (!flag && hit.dist > pad) continue;
              const shade = shadeOut.shade;
              const t = hit.s;
              const side = Math.max(-1, Math.min(1, hit.v / Math.max(1, (profile(t) * k.width) / 2)));
              // Fin rays fan out from the body and sweep back toward the tail.
              const normal = Math.atan2(hit.ny, hit.nx);
              const rayAngle = normal + (t >= 0.8 ? -Math.sign(hit.v) * 1.2 : Math.sign(hit.v) * 0.7);
              let hits = flag ? 1 : 0;
              for (let s = 0; s < SUBSAMPLES.length; s += 2) if (sample(k, totalLen, x + SUBSAMPLES[s] * grid.cw, y + SUBSAMPLES[s + 1] * grid.ch)) hits++;
              if (!hits) continue;
              const i = r * grid.cols + c;
              const key = (hash(Math.round(t * 40), Math.round(side * 4), k.seed) * 4294967295) >>> 0;
              if (eyes.includes(i)) {
                cells.set(i, depth + 0.5, blockIndex(3, 0.1), G.o, C.eye);
                continue;
              }
              if (flag === 2) {
                // Fins and tail: translucent, just their rays over the water.
                cells.set(i, depth - 0.2, 0, slope(rayAngle), C.fin, hits >= 4 ? 230 : 130);
                continue;
              }
              if (flag === 3 && hits >= 3) {
                cells.set(i, depth, blockIndex(0, 0.95), slope(normal + Math.PI / 2), C.bodyInk, 170);
                continue;
              }
              if (!flag || hits < 3) {
                // Soft edge: a light glyph, no block.
                cells.set(i, depth - 0.5, 0, [G["."], G["'"], G[","], G["`"], G[":"]][(key >>> 8) % 5], C.bodyInk, Math.round((0.35 + hits * 0.12) * 255));
                continue;
              }
              // Pattern in body coordinates, so markings travel with the fish.
              const n = noise(t * 7, side * 1.8 + 10, k.seed);
              const m = noise(t * 9 + 40, side * 2.2, k.seed + 7);
              let layer = 0;
              let ink: number = C.bodyInk;
              if (k.variety.sumi > 0 && m < k.variety.sumi + 0.15 && t > 0.1) {
                layer = 3;
                ink = C.sumiInk;
              } else if (n < k.variety.patch && t > 0.04 && t < 0.84) {
                layer = noise(t * 4, side + 3, k.seed + 3) < k.variety.deep ? 2 : 1;
                ink = C.patchInk;
              }
              const glyph = RAMP[Math.max(0, Math.min(RAMP.length - 1, Math.floor((1 - shade) ** 1.25 * (RAMP.length - 0.01)) + (((key >>> 4) % 3) - 1)))];
              cells.set(i, depth, blockIndex(layer, shade), glyph, ink, 200);
            }
          }
        };
    
        const drawPads = () => {
          for (const p of pads) {
            const c0 = Math.max(0, Math.floor((p.x - p.r) / grid.cw));
            const c1 = Math.min(grid.cols - 1, Math.floor((p.x + p.r) / grid.cw));
            const r0 = Math.max(0, Math.floor((p.y - p.r) / grid.ch));
            const r1 = Math.min(grid.rows - 1, Math.floor((p.y + p.r) / grid.ch));
            for (let r = r0; r <= r1; r++) {
              for (let c = c0; c <= c1; c++) {
                const dx = (c + 0.5) * grid.cw - p.x;
                const dy = ((r + 0.5) * grid.ch - p.y) / 0.9;
                const d = Math.hypot(dx, dy) / p.r;
                if (d > 1) continue;
                // A wedge-shaped notch cut toward the center.
                const a = Math.atan2(dy, dx);
                const rel = Math.atan2(Math.sin(a - p.notch - p.spin), Math.cos(a - p.notch - p.spin));
                if (Math.abs(rel) < 0.3 && d > 0.12) continue;
                const i = r * grid.cols + c;
                if (d > 1 - grid.cw / p.r) cells.set(i, 0.3, padStyle, slope(a + Math.PI / 2), C.line, 230);
                else cells.set(i, 0.3, padStyle, hash(c, r, 5) < 0.12 ? slope(a) : 255, C.line, 120);
              }
            }
          }
        };
    
        const render = () => {
          ctx.setTransform(1, 0, 0, 1, 0, 0);
          if (background) ctx.drawImage(background, 0, 0);
          if (!fish.length) return;
          ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
          cells.clear();
          drawPads();
          fish.forEach((k, order) => drawKoi(k, order));
          for (const f of food) cells.set(cellAt(grid, f.x, f.y), 0.4, 0, G["*"], C.food);
          // Wakes and ripples light the water.
          const e = wake.energy;
          const size = Math.max(2, Math.round(grid.cw * 0.3));
          for (let level = 0; level < wakeStyles.length; level++) {
            ctx.fillStyle = wakeStyles[level];
            for (let i = 0; i < e.length; i++) {
              if (e[i] < 0.16 || cells.depth[i] >= 0) continue;
              if (Math.min(wakeStyles.length - 1, Math.floor((e[i] - 0.16) * 5)) !== level) continue;
              ctx.fillRect(Math.round((i % grid.cols) * grid.cw + grid.cw * 0.6), Math.round(((i / grid.cols) | 0) * grid.ch + grid.ch * 0.6), size, size);
            }
          }
          cells.draw(ctx, grid, blockStyles, atlas);
        };
    
        const loop = runCanvasLoop({
          host: root,
          canvas,
          isPaused: () => state.paused,
          resize: (frame) => {
            width = frame.width;
            height = frame.height;
            dpr = frame.dpr;
            const next = cellGrid(width, height, Math.max(6, Math.round(cell)));
            if (next.cols !== grid.cols || next.rows !== grid.rows) {
              wake = new WakeField(next.cols, next.rows);
              cells = new CellBuffer(next.cols * next.rows);
            }
            grid = next;
            buildAtlas();
            paintBackground();
            if (!fish.length) for (let i = 0; i < count; i++) fish.push(spawn(i));
            if (!pads.length) {
              for (let i = 0; i < lilyPads; i++) {
                pads.push({
                  x: width * (0.1 + hash(i, 21) * 0.8),
                  y: height * (0.1 + hash(i, 22) * 0.8),
                  r: grid.cw * (3.5 + hash(i, 23) * 2.5),
                  vx: (hash(i, 24) - 0.5) * 4,
                  vy: (hash(i, 25) - 0.5) * 3,
                  spin: hash(i, 26) * 6,
                  notch: hash(i, 27) * Math.PI * 2,
                });
              }
            }
            // Still frames show the koi mid-swim rather than in a straight line.
            if (frame.still && time === 0) for (let s = 0; s < 120; s++) step(1 / 60);
          },
          render: ({ dt }) => {
            if (dt > 0) step(dt);
            render();
          },
        });
        state.loop = loop;
    
        const local = (e: PointerEvent) => {
          const rect = root.getBoundingClientRect();
          return { x: e.clientX - rect.left, y: e.clientY - rect.top };
        };
        const onMove = (e: PointerEvent) => {
          if (e.pointerType === "touch") return;
          const p = local(e);
          const dt = Math.max(0.008, (e.timeStamp - pointer.stamp) / 1000);
          pointer.vx = (p.x - pointer.x) / dt;
          pointer.vy = (p.y - pointer.y) / dt;
          pointer.x = p.x;
          pointer.y = p.y;
          pointer.stamp = e.timeStamp;
          pointer.t = time;
          pointer.inside = true;
        };
        const onLeave = () => {
          pointer.inside = false;
        };
        const onDown = (e: PointerEvent) => {
          if (!state.interactive || reduceMotion.matches || state.paused) return;
          // Clicks on content (links, buttons, fields) are theirs, not food.
          if ((e.target as HTMLElement).closest("a, button, input, textarea, select, label, [role=button]")) return;
          const p = local(e);
          for (let i = 0; i < 3; i++) food.push({ x: p.x + (hash(food.length + i, total, 3) - 0.5) * grid.cw * 2, y: p.y + (hash(food.length + i, total, 4) - 0.5) * grid.ch * 2, t: time });
          wake.ring(grid, p.x, p.y, grid.cw * 2.5, 1.2);
          if (food.length > 24) food.splice(0, food.length - 24);
          loop.wake();
        };
        root.addEventListener("pointermove", onMove);
        root.addEventListener("pointerleave", onLeave);
        root.addEventListener("pointerdown", onDown);
    
        loadGlyphFont({ glyphs: GLYPHS, fontFamily, fontWeight: 600 }).then(() => {
          if (disposed) return;
          buildAtlas();
          loop.redraw();
        });
    
        return () => {
          disposed = true;
          state.loop = null;
          loop.dispose();
          root.removeEventListener("pointermove", onMove);
          root.removeEventListener("pointerleave", onLeave);
          root.removeEventListener("pointerdown", onDown);
        };
      }, [koiCount, cell, lilyPads, colorKey, themeKey]);
    
      return (
        <div ref={rootRef} className={cn("relative isolate overflow-hidden font-mono", className)} style={{ background: colors.background, ...style }} {...props}>
          <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 size-full" />
          {showCounter ? (
            <span aria-hidden="true" className="pointer-events-none absolute top-3 right-4 font-mono text-[11px] tabular-nums" style={{ color: colors.bodyInk }}>
              fed {fed}
            </span>
          ) : null}
          {children}
        </div>
      );
    }

Usage

import { AsciiKoiPond } from "@/components/ui/ascii-koi-pond/ascii-koi-pond";

<AsciiKoiPond koi={5} palette="light" className="h-[480px]">
  <Hero />
</AsciiKoiPond>

API reference

PropTypeDefaultDescription
childrenReactNode–Content above the pond, e.g. a hero.
koinumber5How many koi swim in the pond (1–12).
cellnumber8Cell width in px; cells are about 1.45 times as tall and glyphs scale with them.
paletteKoiPondPalette"light"Built-in light or dark colors, or your own (any CSS color). An object starts from `base`.
lilyPadsnumber3Drifting lily pads drawn as outlines.
interactivebooleantrueKoi drift toward the cursor, scatter from fast moves, and a click drops food.
showCounterbooleantrueShow how many pellets the koi have eaten in the top-right corner.
onFeed(total: number) => void–Called with the running total each time a koi eats a pellet.
pausedbooleanfalseFreezes the pond on its current frame.

Also accepts every native <div> attribute, including ref and className.

  • Seen from above, each koi is a follow-the-leader spine of 16 segments with a body profile (head, shoulders, tapering body) and a travelling wave that grows toward the tail. Cells are about 1.45 times as tall as they are wide, like a terminal.
  • Every covered cell gets a shaded block lit from the top left and a glyph whose density follows the shade: sparse . : on lit scales, dense % # @ in shadow. Five sub-samples per cell decide coverage, so partly covered edge cells get a light glyph instead of a hard block. Fins and the tail are translucent blocks with fin rays (- \\ | /).
  • Patterns (pale scales, orange and red patches, dark markings) come from value noise in each fish's own body coordinates, so they travel with the fish. Four varieties are mixed in.
  • Wakes and food ripples go into a diffusing energy field that lights small dots in the water and fades. Lily pads drift past as notched outlines. Pass `palette` as an object to recolor any part; it starts from `base`.
  • The rendering approach (top-down bodies, shaded blocks with density-matched glyphs, sub-sampled edges, a wake field) learns from ascii-sharks by drewdi (MIT); this is an independent implementation.

Accessibility

  • The pond is a decorative canvas (aria-hidden) behind your content, which keeps its own semantics and tab order. The fed counter is decorative too; use onFeed to announce it if it matters.
  • Clicks on links, buttons and form fields belong to them, never become food. Following and scattering react to a mouse or pen only, so touch scrolling is untouched.
  • Under reduced motion the pond draws a single still frame and clicks don't drop food. It pauses offscreen and in hidden tabs.