Cybernetic Glyph Grid

A Canvas 2D grid of hex pairs and glitch glyphs that pulse, flicker and re-randomize on a deterministic timer.

Canvas 2DglyphshexglitchHUD · 3 files · 936 lines
Canvas 2D

<CyberneticGlyphGrid isAnimating={true} tickMs={100} updateRatio={0.12} />

man cybernetic-glyph-grid

A line ref opens it in the source below

Name

cybernetic-glyph-grid — a Canvas 2D grid of hex pairs that glitch and settle, drawn from a glyph atlas.

How it works

  1. Text drawn once. A glowing fillText (shadowBlur) is the costly part, so each glyph is drawn once, per value, tone, font size and pixel ratio, on its own small canvas; every frame after that is one drawImage per cell. The xray prints the atlas as it grows.

  2. A scattered rewrite. Every 100 ms, 12 % of the cells are rewritten, picked at (tick × 11 + i × 37 + 17) mod n, each with a new hex pair from its own lcg seed.

  3. Glitch, then settle. When (seed >>> 1) & 0x1f ≤ 2 (3 rewrites in 32) the cell shows one of 37 block tokens (▚▞, ◢◣…) for 92 ms, jittering sideways, then lands on the hex pair it drew in advance.

  4. Fixed accents, a travelling pulse. One cell in 16 is pink for good (((0x2f6a91c3 + i × 13) >>> 2) & 0xf == 0); every cell's alpha breathes on a 2.9 s sine shifted by (i × 17 mod 240) / 70 s.

Cost

  1. Capped. At most 30 draws a second, devicePixelRatio held at 1.25; the atlas is emptied only when the pixel ratio or the font size changes, or once the fonts have loaded.

  2. Idle when still. Paused, or under prefers-reduced-motion: one frame, then no requestAnimationFrame. The xray and Step draw by hand.

See also

import { memo, useEffect, useEffectEvent, useRef, useState } from "react";
import * as styles from "./cybernetic-glyph-grid.css";

export interface CyberneticGlyphGridProps {
  isAnimating: boolean;
  className?: string;
  /** Milliseconds between two rewrites of the grid. */
  tickMs?: number;
  /** Share of the cells rewritten each tick (0–1). */
  updateRatio?: number;
  /** The Lab's xray: cell boxes, the cells this tick rewrote, the glitching ones. */
  xray?: boolean;
  /** Changing it runs one tick by hand (while paused). */
  stepToken?: number;
  /** After each tick. */
  onTick?: (stats: GlyphGridStats) => void;
  /** Whenever the glyph atlas gains a bitmap. */
  onAtlas?: (glyphs: readonly GlyphAtlasEntry[]) => void;
}

export interface GlyphGridStats {
  tick: number;
  cells: number;
  cols: number;
  rows: number;
  rewritten: number;
  /** Rewrites this tick that drew a glitch token. */
  glitched: number;
  /** Cells showing a glitch token right now. */
  glitching: number;
  atlas: number;
}

/** One cached bitmap: its key (`value:tone:size:dpr`) and the canvas drawn once for it. */
export interface GlyphAtlasEntry {
  key: string;
  canvas: HTMLCanvasElement;
  width: number;
  height: number;
}

const HEX = "0123456789ABCDEF";

const GLYPH_GLITCH_TOKENS = [
  "  ",
  " .",
  ". ",
  "░░",
  "▒▒",
  "▓▓",
  "██",
  "█░",
  "░█",
  "▚▞",
  "▞▚",
  "▖▗",
  "▘▝",
  "▙▟",
  "▛▜",
  "◢◣",
  "◤◥",
  "◧◨",
  "◩◪",
  "▔▁",
  "▁▔",
  "▏▕",
  "▕▏",
  "╳╳",
  "╱╲",
  "╲╱",
  "┇┇",
  "┊┊",
  "╍╍",
  "⟦⟧",
  "⟨⟩",
  "◌◌",
  "◍◍",
  "◇◆",
  "◆◇",
  "◎◉",
  "◉◎",
];

const BASE_SEED = 0x2f6a91c3;
const LCG_A = 1664525;
const LCG_C = 1013904223;

const CELL_BASE_W = 16;
const CELL_BASE_H = 13;
const GAP_PX = 2;

const MAX_DPR = 1.25;

const TICK_MS = 100;
// Repaints capped at 30fps: cells mutate every TICK_MS and the pulse period
// is 2.9s, so redrawing on every rAF frame only burns CPU for no visible gain.
const FRAME_MS = 1000 / 30;
const UPDATE_RATIO = 0.12;

const PULSE_PERIOD_S = 2.9;
const PULSE_RADIANS_PER_SECOND = (Math.PI * 2) / PULSE_PERIOD_S;

const GLITCH_DURATION_MS = 92;
const GLITCH_BASE_ALPHA = 0.58;

const FONT_FAMILY = "Doto, system-ui, sans-serif";

export const CYBERNETIC_GLYPH_GRID_DEFAULTS = {
  tickMs: TICK_MS,
  updateRatio: UPDATE_RATIO,
  glitchMs: GLITCH_DURATION_MS,
  seed: BASE_SEED,
} as const;

const lcg = (seed: number): number => (seed * LCG_A + LCG_C) >>> 0;

const hexPair = (seed: number): [string, number] => {
  const next = lcg(seed);
  const value = `${HEX[next & 0x0f]}${HEX[(next >>> 4) & 0x0f]}`;
  return [value, lcg(next)];
};

const glitchToken = (seed: number): [string, number] => {
  const next = lcg(seed);
  return [GLYPH_GLITCH_TOKENS[next % GLYPH_GLITCH_TOKENS.length] ?? "░░", lcg(next)];
};

interface CellState {
  value: string;
  seed: number;
  accent: boolean;
  glitchUntil: number;
  settleValue: string;
  settleSeed: number;
  pulseDelay: number;
}

interface GridLayout {
  cols: number;
  rows: number;
  cellW: number;
  cellH: number;
}

type GlyphTone = "normal" | "accent" | "glitch";

interface GlyphBitmap {
  canvas: HTMLCanvasElement;
  width: number;
  height: number;
}

const TONE_STYLES: Record<
  GlyphTone,
  {
    color: string;
    glow: string;
    shadowBlur: number;
  }
> = {
  normal: {
    color: "oklch(0.82 0.15 130)",
    glow: "oklch(0.8858 0.182 95.69 / 0.12)",
    shadowBlur: 7,
  },
  accent: {
    color: "oklch(0.71 0.17 336)",
    glow: "oklch(0.8858 0.182 95.69 / 0.18)",
    shadowBlur: 10,
  },
  glitch: {
    color: "oklch(0.77 0.21 105)",
    glow: "oklch(0.5548 0.2575 312.98 / 0.2)",
    shadowBlur: 12,
  },
};

const buildCells = (count: number): CellState[] => {
  const cells: CellState[] = [];
  let seed = BASE_SEED;

  for (let index = 0; index < count; index += 1) {
    const [value, nextSeed] = hexPair(seed);
    const cellSeed = nextSeed ^ (index * 2654435761);

    cells.push({
      value,
      seed: cellSeed,
      accent: (((BASE_SEED + index * 13) >>> 2) & 0x0f) === 0,
      glitchUntil: 0,
      settleValue: value,
      settleSeed: cellSeed,
      pulseDelay: ((index * 17) % 240) / 70,
    });

    seed = nextSeed;
  }

  return cells;
};

const computeLayout = (w: number, h: number): GridLayout => {
  const cols = Math.max(8, Math.floor((w - GAP_PX) / (CELL_BASE_W + GAP_PX)));
  const rows = Math.max(6, Math.floor((h - GAP_PX) / (CELL_BASE_H + GAP_PX)));
  const cellW = (w - GAP_PX * (cols + 1)) / cols;
  const cellH = (h - GAP_PX * (rows + 1)) / rows;

  return { cols, rows, cellW, cellH };
};

const makeGlyphKey = (value: string, tone: GlyphTone, fontSize: number, dpr: number) =>
  `${value}:${tone}:${Math.round(fontSize * 100)}:${Math.round(dpr * 100)}`;

const createGlyphBitmap = (
  value: string,
  tone: GlyphTone,
  fontSize: number,
  dpr: number,
): GlyphBitmap => {
  const toneStyle = TONE_STYLES[tone];

  const measureCanvas = document.createElement("canvas");
  const measureCtx = measureCanvas.getContext("2d");

  const font = `700 ${fontSize}px ${FONT_FAMILY}`;

  let textWidth = fontSize * 1.6;

  if (measureCtx) {
    measureCtx.font = font;
    textWidth = measureCtx.measureText(value).width;
  }

  const padding = Math.ceil(toneStyle.shadowBlur + 5);
  const cssWidth = Math.ceil(textWidth + padding * 2);
  const cssHeight = Math.ceil(fontSize * 1.65 + padding * 2);

  const canvas = document.createElement("canvas");
  canvas.width = Math.max(1, Math.ceil(cssWidth * dpr));
  canvas.height = Math.max(1, Math.ceil(cssHeight * dpr));

  const ctx = canvas.getContext("2d", {
    alpha: true,
    desynchronized: true,
  });

  if (ctx) {
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.imageSmoothingEnabled = false;

    ctx.font = font;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";

    ctx.fillStyle = toneStyle.color;
    ctx.shadowColor = toneStyle.glow;
    ctx.shadowBlur = toneStyle.shadowBlur * dpr;

    ctx.fillText(value, cssWidth / 2, cssHeight / 2);
  }

  return {
    canvas,
    width: cssWidth,
    height: cssHeight,
  };
};

export const CyberneticGlyphGrid = memo(
  ({
    isAnimating,
    className,
    tickMs = TICK_MS,
    updateRatio = UPDATE_RATIO,
    xray = false,
    stepToken = 0,
    onTick,
    onAtlas,
  }: CyberneticGlyphGridProps) => {
    const rootRef = useRef<HTMLDivElement>(null);
    const canvasRef = useRef<HTMLCanvasElement>(null);

    const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
    const cellsRef = useRef<CellState[]>([]);
    const glyphAtlasRef = useRef<Map<string, GlyphBitmap>>(new Map());

    const layoutRef = useRef<GridLayout>({
      cols: 12,
      rows: 8,
      cellW: CELL_BASE_W,
      cellH: CELL_BASE_H,
    });

    const dprRef = useRef(1);
    const canvasWRef = useRef(0);
    const canvasHRef = useRef(0);
    const fontSizeRef = useRef(0);

    const rafRef = useRef<number | null>(null);
    const animStartRef = useRef(0);
    const lastTickRef = useRef(0);
    const tickStepRef = useRef(0);

    const mountedRef = useRef(false);
    // What the last tick rewrote, for the xray; whether the atlas grew since it was reported.
    const touchedRef = useRef<number[]>([]);
    const glitchedRef = useRef(0);
    const atlasGrewRef = useRef(false);
    const xrayRef = useRef(xray);

    const [reducedMotion, setReducedMotion] = useState(false);

    const getGlyph = (
      value: string,
      tone: GlyphTone,
      fontSize: number,
      dpr: number,
    ): GlyphBitmap => {
      const key = makeGlyphKey(value, tone, fontSize, dpr);
      const cached = glyphAtlasRef.current.get(key);

      if (cached) return cached;

      const bitmap = createGlyphBitmap(value, tone, fontSize, dpr);
      glyphAtlasRef.current.set(key, bitmap);
      atlasGrewRef.current = true;

      return bitmap;
    };

    // Effect events: called from the resize observer, font loading and the frame
    // loop, they read the latest props without restarting those effects.
    const ensureCells = useEffectEvent((count: number) => {
      if (cellsRef.current.length === count) return;
      cellsRef.current = buildCells(count);
    });

    const updateCells = useEffectEvent((now: number) => {
      tickStepRef.current += 1;

      const cells = cellsRef.current;
      const count = cells.length;

      if (count === 0) return;

      const batch = Math.max(2, Math.floor(count * updateRatio));
      const step = tickStepRef.current;
      const touched: number[] = [];
      let glitched = 0;

      for (let i = 0; i < batch; i += 1) {
        const ci = (step * 11 + i * 37 + 17) % count;
        const cell = cells[ci];

        if (!cell) continue;
        touched.push(ci);

        const shouldGlitch = ((cell.seed >>> 1) & 0x1f) <= 2;
        if (shouldGlitch) glitched += 1;
        const [nextValue, nextSeed] = shouldGlitch ? glitchToken(cell.seed) : hexPair(cell.seed);

        cell.value = nextValue;
        cell.seed = nextSeed;

        if (shouldGlitch) {
          const settleSeed = lcg(nextSeed);
          const [settleValue, stableSeed] = hexPair(settleSeed);

          cell.glitchUntil = now + GLITCH_DURATION_MS;
          cell.settleValue = settleValue;
          cell.settleSeed = stableSeed;
        } else {
          cell.glitchUntil = 0;
        }
      }

      touchedRef.current = touched;
      glitchedRef.current = glitched;
    });

    // The readout's numbers, and the atlas when it grew. Only while someone listens.
    const report = useEffectEvent((now: number) => {
      if (onTick) {
        const { cols, rows } = layoutRef.current;
        const cells = cellsRef.current;
        onTick({
          tick: tickStepRef.current,
          cells: cells.length,
          cols,
          rows,
          rewritten: touchedRef.current.length,
          glitched: glitchedRef.current,
          glitching: cells.filter((cell) => cell.glitchUntil > now).length,
          atlas: glyphAtlasRef.current.size,
        });
      }
      if (onAtlas && atlasGrewRef.current) {
        atlasGrewRef.current = false;
        onAtlas(
          [...glyphAtlasRef.current].map(([key, glyph]) => ({
            key,
            canvas: glyph.canvas,
            width: glyph.width,
            height: glyph.height,
          })),
        );
      }
    });

    const tick = useEffectEvent((now: number) => {
      updateCells(now);
      report(now);
    });

    const tickIsDue = useEffectEvent((now: number) => now - lastTickRef.current >= tickMs);

    const draw = useEffectEvent((now: number) => {
      const ctx = ctxRef.current;
      if (!ctx) return;

      const dpr = dprRef.current;
      const { cols, rows, cellW, cellH } = layoutRef.current;
      const cells = cellsRef.current;
      const w = canvasWRef.current;
      const h = canvasHRef.current;

      if (w <= 0 || h <= 0 || cells.length === 0) return;

      const running = isAnimating && !reducedMotion;
      const elapsed = running ? (now - animStartRef.current) / 1000 : 0;
      const fontSize = fontSizeRef.current;

      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.globalAlpha = 1;
      ctx.shadowBlur = 0;
      ctx.globalCompositeOperation = "source-over";

      const bg = ctx.createLinearGradient(0, 0, 0, h);
      bg.addColorStop(0, "oklch(0.13 0.03 210 / 0.92)");
      bg.addColorStop(1, "oklch(0.095 0.02 225 / 0.96)");

      ctx.fillStyle = bg;
      ctx.fillRect(0, 0, w, h);

      for (let row = 0; row < rows; row += 1) {
        for (let col = 0; col < cols; col += 1) {
          const idx = row * cols + col;
          const cell = cells[idx];

          if (!cell) continue;

          if (cell.glitchUntil > 0 && now >= cell.glitchUntil) {
            cell.value = cell.settleValue;
            cell.seed = cell.settleSeed;
            cell.glitchUntil = 0;
          }

          const isGlitch = cell.glitchUntil > 0;
          const tone: GlyphTone = isGlitch ? "glitch" : cell.accent ? "accent" : "normal";

          let alpha: number;
          let offsetX = 0;

          if (isGlitch) {
            const phase = (now * 0.0053 + idx * 0.731) % 1;

            alpha = GLITCH_BASE_ALPHA + 0.37 * Math.abs(Math.sin(phase * Math.PI));
            offsetX = Math.sin(phase * 7.7) * 0.6;
          } else {
            alpha = running
              ? 0.54 +
                0.32 *
                  (Math.sin((elapsed + cell.pulseDelay) * PULSE_RADIANS_PER_SECOND - Math.PI / 2) *
                    0.5 +
                    0.5)
              : 0.62;
          }

          const cx = GAP_PX + col * (cellW + GAP_PX) + cellW / 2;
          const cy = GAP_PX + row * (cellH + GAP_PX) + cellH / 2;

          const glyph = getGlyph(cell.value, tone, fontSize, dpr);

          ctx.globalAlpha = alpha;
          ctx.drawImage(
            glyph.canvas,
            cx - glyph.width / 2 + offsetX,
            cy - glyph.height / 2,
            glyph.width,
            glyph.height,
          );
        }
      }

      ctx.globalAlpha = 1;

      if (xrayRef.current) {
        const box = (index: number) => {
          const col = index % cols;
          const row = Math.floor(index / cols);
          return [GAP_PX + col * (cellW + GAP_PX), GAP_PX + row * (cellH + GAP_PX)] as const;
        };
        ctx.lineWidth = 1;
        ctx.strokeStyle = "oklch(0.82 0.15 130 / 0.16)";
        for (let index = 0; index < cols * rows; index += 1) {
          const [x, y] = box(index);
          ctx.strokeRect(x + 0.5, y + 0.5, cellW - 1, cellH - 1);
        }
        // Glitching now: violet. Rewritten by the last tick: gold, on top.
        ctx.strokeStyle = "oklch(0.62 0.25 313)";
        cells.forEach((cell, index) => {
          if (cell.glitchUntil <= now || index >= cols * rows) return;
          const [x, y] = box(index);
          ctx.strokeRect(x + 0.5, y + 0.5, cellW - 1, cellH - 1);
        });
        ctx.strokeStyle = "oklch(0.8858 0.182 95.69)";
        ctx.lineWidth = 1.5;
        for (const index of touchedRef.current) {
          if (index >= cols * rows) continue;
          const [x, y] = box(index);
          ctx.strokeRect(x + 0.75, y + 0.75, cellW - 1.5, cellH - 1.5);
        }
      }
    });

    useEffect(() => {
      const mq = window.matchMedia("(prefers-reduced-motion: reduce)");

      const update = () => {
        setReducedMotion(mq.matches);
      };

      update();
      mq.addEventListener("change", update);

      return () => {
        mq.removeEventListener("change", update);
      };
    }, []);

    useEffect(() => {
      const canvas = canvasRef.current;
      if (!canvas) return;

      ctxRef.current = canvas.getContext("2d", {
        alpha: false,
        desynchronized: true,
      });

      if (ctxRef.current) {
        ctxRef.current.imageSmoothingEnabled = false;
      }

      return () => {
        ctxRef.current = null;
      };
    }, []);

    useEffect(() => {
      const el = rootRef.current;
      const canvas = canvasRef.current;

      if (!el || !canvas) return;

      const resize = () => {
        const rect = el.getBoundingClientRect();
        const w = Math.floor(rect.width);
        const h = Math.floor(rect.height);

        if (w <= 0 || h <= 0) return;

        const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
        const layout = computeLayout(w, h);
        const fontSize = Math.max(7, layout.cellH * 0.72);
        const count = layout.cols * layout.rows;

        const dprChanged = dprRef.current !== dpr;
        const fontChanged = Math.abs(fontSizeRef.current - fontSize) > 0.25;

        dprRef.current = dpr;
        canvasWRef.current = w;
        canvasHRef.current = h;
        layoutRef.current = layout;
        fontSizeRef.current = fontSize;

        canvas.width = Math.max(1, Math.ceil(w * dpr));
        canvas.height = Math.max(1, Math.ceil(h * dpr));

        if (dprChanged || fontChanged) {
          glyphAtlasRef.current.clear();
        }

        ensureCells(count);
        draw(performance.now());
      };

      resize();

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

      return () => {
        ro.disconnect();
      };
    }, []);

    useEffect(() => {
      let cancelled = false;

      document.fonts?.ready.then(() => {
        if (cancelled) return;

        glyphAtlasRef.current.clear();
        draw(performance.now());
      });

      return () => {
        cancelled = true;
      };
    }, []);

    useEffect(() => {
      if (rafRef.current !== null) {
        cancelAnimationFrame(rafRef.current);
        rafRef.current = null;
      }

      if (!isAnimating || reducedMotion) {
        draw(performance.now());
        return;
      }

      mountedRef.current = true;
      animStartRef.current = performance.now();
      lastTickRef.current = animStartRef.current;
      tickStepRef.current = 0;

      let lastDraw = 0;

      const loop = (now: number) => {
        if (!mountedRef.current) return;

        if (!document.hidden && tickIsDue(now)) {
          lastTickRef.current = now;
          tick(now);
        }

        if (!document.hidden && now - lastDraw >= FRAME_MS) {
          lastDraw = now;
          draw(now);
        }

        rafRef.current = requestAnimationFrame(loop);
      };

      rafRef.current = requestAnimationFrame(loop);

      return () => {
        mountedRef.current = false;

        if (rafRef.current !== null) {
          cancelAnimationFrame(rafRef.current);
          rafRef.current = null;
        }
      };
    }, [isAnimating, reducedMotion]);

    // Paused or under reduced motion, nothing redraws on its own: the xray and a step do. A
    // readout opening gets the grid and the whole atlas as they are, not after the next tick.
    useEffect(() => {
      xrayRef.current = xray;
      const now = performance.now();
      draw(now);
      if (!xray) return;
      atlasGrewRef.current = true;
      report(now);
    }, [xray]);

    useEffect(() => {
      if (stepToken === 0) return;
      const now = performance.now();
      tick(now);
      draw(now);
    }, [stepToken]);

    const rootClassName = className ? `${styles.rootStyles} ${className}` : styles.rootStyles;

    return (
      <div ref={rootRef} className={rootClassName} aria-hidden="true">
        <canvas ref={canvasRef} className={styles.canvasStyles} />
        <div className={styles.noiseOverlayStyles} />
      </div>
    );
  },
);
cybernetic-glyph-grid.component.tsx · 695 lines · 18K · tsx_Source_