Skip to main content
Back to discovery

Preview supplied by Arlan Marat; live previews were recorded from Arlan's Vault when no published video file was available. Preview: platform recorded · rights cleared.

Arcade pixel

A live WebGL type treatment that enlarges a tiny word mask into hard pixel blocks while threshold, texture, and pointer-driven layers reshape its edges.

VSource LandVault

Why it stands out

The component draws its word at low resolution and scales the result up so every source pixel becomes a visible square. Threshold controls move the cut through the letterforms, while keylines, print texture, color separation, and pointer magnetism give the result the feel of a shifting arcade poster.

Prompt

You are given the complete source for a self-contained web visual: "Arcade pixel".

What it is:
- The letters look pixelated, but there is no pixel grid anywhere: the word is just drawn tiny and blown up, so every little pixel becomes a big square.
- Type your own word below and drag the two sliders that make the whole thing work.

How to use this code:
- It is a React + TypeScript component tree (Next.js App Router) that renders to an HTML canvas; the entry point is the playground component.
- Drop the files into a project under the same relative paths, mount the playground component, and it runs as-is. No external assets beyond what's inline.
- To adapt it: change the resolved word/logo, the color controls, or the experiment parameters. Ask me to modify, explain, or port any part.

The full source follows, one file per block:

### arcade/params.ts
```ts
// Arcade pixel type — the colourways and the numbers that define the effect.
//
// Reverse-engineered from a Photoshop template (4500x3000, RGB/8). The whole
// stack in that file is five things:
//
//   Background        solid #EB0809
//   Effect/Layer      the word, as a smart object
//   Effect/Threshold  150, blending as COLOR BURN   <- the entire effect
//   Textures/         two gradients + Noise (hard light) + Dust (screen)
//                     + Noise (linear light 70%)
//   Settings/         Vibrance +20, Levels in 5->230 gamma 1.00
//
// Every number below was measured off that file rather than eyeballed.

export type Colorway = {
  name: string;
  /** The ground the word is burnt into. */
  ground: [number, number, number];
  /** The dark ink. Measured at #050605 in the source — NOT pure black, which is
   *  worth keeping: a hair of blue in the shadow stops the poster reading as a
   *  flat two-bit stencil. */
  ink: [number, number, number];
  /** The light face of the letters. */
  paper: [number, number, number];
  /** Tint of the coloured speckle along the quantised edges. */
  fringe: [number, number, number];
};

/** The measured original first — it is the card's resting identity. */
export const COLORWAYS: Colorway[] = [
  {
    // Straight off the PSD: a hot, almost pure red.
    name: "Arcade Red",
    ground: [0.922, 0.031, 0.035], // #EB0809
    ink: [0.02, 0.024, 0.02], // #050605
    paper: [1, 1, 1],
    fringe: [0.35, 1, 0.9],
  },
  {
    name: "Acid",
    ground: [0.804, 1, 0.05],
    ink: [0.04, 0.06, 0.02],
    paper: [1, 1, 0.96],
    fringe: [1, 0.2, 0.75],
  },
  {
    name: "Cyanide",
    ground: [0.04, 0.85, 0.92],
    ink: [0.01, 0.05, 0.08],
    paper: [0.96, 1, 1],
    fringe: [1, 0.35, 0.2],
  },
  {
    name: "Monochrome",
    // The one restrained colourway. With no hue to carry the poster, the
    // quantisation itself has to do all the work — which is the best possible
    // demonstration that the effect is the threshold and not the palette.
    ground: [0.9, 0.89, 0.87],
    ink: [0.04, 0.04, 0.05],
    paper: [1, 1, 1],
    fringe: [0.5, 0.55, 0.6],
  },
  {
    name: "Ultraviolet",
    ground: [0.36, 0.05, 0.85],
    ink: [0.03, 0.01, 0.08],
    paper: [0.95, 0.92, 1],
    fringe: [1, 0.85, 0.2],
  },
  {
    name: "Ember",
    ground: [1, 0.42, 0.02],
    ink: [0.08, 0.02, 0.0],
    paper: [1, 0.97, 0.9],
    fringe: [0.2, 0.7, 1],
  },
];

export interface ArcadeParams {
  word: string;
  /**
   * Where the quantised edge lands, 0..1. The PSD's Threshold is 150/255.
   *
   * This is HALF the effect. Moving it does not just brighten the letters — it
   * slides the cut along the blur ramp, so the staircase steps land in different
   * places and the letterforms visibly gain and lose blocks.
   */
  threshold: number;
  /**
   * Cells ACROSS the low-res grid. THIS IS THE BLOCK SIZE.
   *
   * Measured on the source: its smart object is 3750px wide and every edge in
   * both axes falls on an exact 25px boundary, so the art is a 150-cell grid
   * scaled up 25x. Lower is chunkier.
   *
   * Not a blur radius. Thresholding a smooth blur was an earlier attempt and it
   * does not step an edge at all, it only moves it — the blocks have to come
   * from real low resolution.
   */
  cols: number;
  /** White halo width in CELLS. Source: a 73px outside stroke / 25px = ~3. */
  halo: number;
  /** Black keyline width in CELLS. Source: 103px / 25px = ~4. It is WIDER than
   *  the halo, which is why the black shows as a band beyond the white. */
  keyline: number;
  /** How far the black keyline is pushed, in CELLS, as [dx, dy]. The source is
   *  symmetric ([0,0]); offsetting turns the outline into a hard drop shadow. */
  keylineOffset: [number, number];
  /** The source's type is a faux-italic Alternate Gothic. */
  italic: boolean;
  /** Surface strength, scaling all three real plates together.
   *
   *  1.0 is the full print. The blend amounts in the shader are already tuned so
   *  that 1.0 gives roughly the same swing on the red ground, the white letter
   *  faces and the black keyline (~0.48 each), which is what the reference does
   *  — its white faces are every bit as printed as its background. */
  texture: number;
  /** C2: the drop shadow follows the pointer. */
  magnet: boolean;
  /** How far the magnetic shadow can travel beyond its resting offset, in CELLS. */
  magnetReach: number;
  /** How far the face/halo/keyline drift toward the pointer, in uv. Small — the
   *  layers should breathe, not slide. */
  parallax: number;
  /** Magnetism: how far the whole word leans toward the pointer, in uv. */
  pull: number;
  /** S3: how hard the cursor rotates one moire grating against the other. */
  swim: number;
  /** Per-channel offset on the moire, as print misregistration. */
  separation: number;
  /** Colourway index. */
  colorway: number;
}

export const DEFAULTS: ArcadeParams = {
  word: "arcade",
  // 150/255 = 0.588, straight from the file.
  // 150/255 = 0.588, straight from the file.
  threshold: 0.588,
  // The source's own grid. At a 1344px-wide card this is a ~9px block.
  cols: 150,
  halo: 3,
  // 3, not 4. The keyline is a band BEYOND the white halo, so its width and its
  // offset compound — a 4-cell band pushed 2 cells reads as a slab behind the
  // word rather than an edge on it.
  keyline: 3,
  // ONE cell, down and right. Two cells detached the shadow from the letter —
  // at this grid a cell is ~9px, so a 2-cell offset throws the shadow 18px clear
  // of a letter whose stroke is only a few cells thick, and the two stop reading
  // as one object. One cell is a hairline of depth, which is all this needs.
  keylineOffset: [1, 1],
  italic: true,
  // A QUARTER. The moire, grain and dust plates are lifted from the PSD and at
  // anything near full strength they are the loudest thing on the card — the
  // interference pattern starts reading as the subject and the type becomes the
  // background it sits on. At 0.25 the surface is still plainly there (the
  // printed-poster feel survives, which is the whole reason the plates exist)
  // but it stays a texture ON the letters rather than a pattern competing WITH
  // them. The slider still runs past 1 for anyone who wants it heavier.
  texture: 0.25,
  magnet: true,
  // The magnet swings the shadow much less far now. At 3 cells the shadow
  // travelled far enough to sit beside the word rather than under it, which
  // undid the point of shrinking the resting offset.
  magnetReach: 1,
  swim: 1,
  // ~0.9% of the frame at full reach, so the shadow travels about 12px and the
  // face about 4px. Past ~0.02 the layers visibly come apart and the letter
  // stops looking like one object.
  parallax: 0.009,
  // ~1.6% of the frame at most, so the word shifts about 20px when the cursor is
  // right beside it. Deliberately more than the parallax: the lean is the thing
  // you notice, the layer separation is the thing that makes it convincing.
  pull: 0.016,
  separation: 1,
  colorway: 0,
};

/** Stripe angle of the noise texture, in degrees.
 *
 *  Measured at ~80.5deg off the source — very close to vertical but deliberately
 *  NOT vertical. A true 90 reads as a screen artefact or a broken display; a few
 *  degrees of lean reads as a printing pass. */
export const TEXTURE_ANGLE = 80.5;

/** Stripe period as a fraction of card width. ~29px at 4500 wide. */
export const TEXTURE_PERIOD = 0.0065;

/** Levels from the source: input 5..230, gamma 1.00 (a pure contrast crush with
 *  no midtone shift), and Vibrance +20. */
export const LEVELS_IN = [5 / 255, 230 / 255] as const;
export const VIBRANCE = 0.2;

/** The face. The site's heaviest shipped weight — the effect needs fat letters,
 *  because a thin stroke does not survive being quantised into blocks. */
export const FONT_CSS = "var(--font-neue-montreal)";
export const FONT_WEIGHT = 600;

```

### arcade/text-mask.ts
```ts
// Rasterises the word into the three-channel field the shader paints.
//
//   R = the letter FACE      (dark ink)
//   G = the WHITE halo       (face dilated ~3 blocks)
//   B = the BLACK keyline    (face dilated ~4 blocks)
//
// ── WHAT THE SOURCE ACTUALLY DOES ───────────────────────────────────────────
//
// This was rebuilt after decoding the real PSD, and almost every guess made from
// the layer NAMES turned out to be wrong. What is actually in the file:
//
//   The word lives in an embedded smart object (Edit Content.psb) whose group
//   carries TWO Photoshop stroke effects, both position "outside":
//       73px  solid WHITE
//      103px  solid #1F1E1C (near-black)
//   The black is WIDER, so it shows as a band beyond the white — that is the
//   halo-then-keyline you see, and it is two real strokes rather than anything
//   derived from a threshold.
//
//   THE ART IS ALREADY PIXELATED BEFORE ANY ADJUSTMENT RUNS. Rendering the smart
//   object on its own — no Threshold, no textures — gives blocky letters AND
//   blocky strokes. Measured on that render: every run length and every edge in
//   both axes falls on an exact 25px boundary, in a 3750x975 object. So the
//   source is a 150 x 39 bitmap scaled up 25x, and the strokes were applied at
//   that low resolution, which is why they staircase in perfect register with
//   the glyph instead of rounding their own corners.
//
//   The Threshold 150 in color-burn on top is therefore NOT the effect. It only
//   crushes what antialiasing survives the upscale. My first two builds treated
//   it as the whole mechanism and produced soft round letters, because
//   thresholding a smooth ramp MOVES an edge without ever stepping it.
//
// So: rasterise small, dilate the strokes IN CELLS, then scale up with nearest
// neighbour. Everything quantises together because everything is computed on the
// same tiny grid.

import { FONT_WEIGHT as WEIGHT } from "./params";

export interface FieldOpts {
  word: string;
  /** Cells across the low-res grid. The source is 150 for a 3750px object. */
  cols: number;
  /** White halo width, in CELLS. Source: 73px / 25px = ~3. */
  halo: number;
  /** Black keyline width, in CELLS. Source: 103px / 25px = ~4. */
  keyline: number;
  /** How far the keyline is pushed, in CELLS, as [dx, dy].
   *
   *  The source's stroke is symmetric — it rings the letter evenly. Offsetting it
   *  turns the same shape into a hard DROP SHADOW, which is the arcade-cabinet
   *  read: a sticker printed slightly out of register, or a marquee letter with a
   *  block shadow behind it. In cells rather than pixels so the shadow lands
   *  exactly on the lattice and staircases in step with the letter instead of
   *  sliding half a block out of register. */
  keylineOffset: [number, number];
  /** Italic, like the source's faux-italic Alternate Gothic. */
  italic: boolean;
  w: number;
  h: number;
  fontFamily: string;
}

/** Grow a mask by `cells` in every direction, on the low-res grid.
 *
 *  A real morphological dilate on a handful of thousand cells — cheap, and
 *  exact. Doing this with a blur + threshold instead (the obvious shortcut)
 *  rounds the corners, and rounded corners on a blocky letter is precisely the
 *  thing that makes a rebuild of this look wrong. */
function dilate(
  src: Uint8Array,
  cols: number,
  rows: number,
  cells: number,
): Uint8Array {
  let cur = src;
  for (let pass = 0; pass < cells; pass++) {
    const out = new Uint8Array(cols * rows);
    for (let y = 0; y < rows; y++) {
      for (let x = 0; x < cols; x++) {
        const i = y * cols + x;
        if (cur[i]) {
          out[i] = 1;
          continue;
        }
        // 4-neighbourhood: grows a square, which is what a pixel-grid stroke
        // looks like. An 8-neighbourhood grows a diamond-ish blob and softens
        // the corners the whole effect depends on.
        if (
          (x > 0 && cur[i - 1]) ||
          (x < cols - 1 && cur[i + 1]) ||
          (y > 0 && cur[i - cols]) ||
          (y < rows - 1 && cur[i + cols])
        ) {
          out[i] = 1;
        }
      }
    }
    cur = out;
  }
  return cur;
}

/** SYNCHRONOUS on purpose. It is pure canvas work — a rasterise, a threshold and
 *  two dilates over ~10k cells, well under a millisecond — and the resolution
 *  animation calls it straight from the frame loop. An async signature would
 *  force that caller to await a promise that never actually yields, which lands
 *  the new mask a frame late and makes the collapse stutter. */
export function makeArcadeField(o: FieldOpts): HTMLCanvasElement {
  const W = Math.max(1, Math.round(o.w));
  const H = Math.max(1, Math.round(o.h));

  // ── 1. the LOW-RES grid ───────────────────────────────────────────────────
  const cols = Math.max(24, Math.round(o.cols));
  const rows = Math.max(8, Math.round(cols * (H / W)));

  const low = document.createElement("canvas");
  low.width = cols;
  low.height = rows;
  const lx = low.getContext("2d", { willReadFrequently: true })!;
  lx.clearRect(0, 0, cols, rows);

  const text = (o.word || "").trim();
  if (text) {
    // Leave room for the keyline: the strokes grow OUTWARD, so a word sized to
    // the full width loses its outline off the edge of the card.
    const pad = o.keyline + 2;
    // The source's cap height is ~746px of a 975px object = 0.765, and its type
    // is set in Alternate Gothic — a very condensed face. Ours is not condensed,
    // so it is sized to fit the width and the height is what it is.
    let size = rows * 0.46;
    const fit = (s: number) => {
      lx.font = `${o.italic ? "italic " : ""}${WEIGHT} ${s}px ${o.fontFamily}`;
      return lx.measureText(text).width;
    };
    // Well inside the frame. The word used to run nearly edge to edge, which
    // left the poster no ground to be a poster — the surface texture and the
    // drop shadow both need open colour around the type to read at all.
    const maxW = (cols - pad * 2) * 0.72;
    if (fit(size) > maxW) size *= maxW / fit(size);
    lx.font = `${o.italic ? "italic " : ""}${WEIGHT} ${size}px ${o.fontFamily}`;
    lx.fillStyle = "#fff";
    lx.textAlign = "center";
    lx.textBaseline = "middle";
    lx.fillText(text, cols / 2, rows * 0.5);
  }

  // ── 2. hard-quantise to a boolean grid ────────────────────────────────────
  // The rasteriser antialiases; the source has no grey at all. Cut at 50% so a
  // cell is either ink or it is not, which is what makes the dilate below
  // produce clean square growth.
  const src = lx.getImageData(0, 0, cols, rows).data;
  const face = new Uint8Array(cols * rows);
  for (let i = 0, c = 0; i < src.length; i += 4, c++) {
    face[c] = src[i + 3] > 127 ? 1 : 0;
  }

  // ── 3. the two strokes, grown in CELLS ────────────────────────────────────
  const halo = dilate(face, cols, rows, o.halo);
  // The keyline is dilated FIRST and shifted after, not the other way round:
  // shifting the source and then growing it would spread the shadow evenly
  // around the moved shape, which is the same symmetric ring again just in the
  // wrong place. Growing then translating keeps the band tight to the letter and
  // puts all of the extra weight on one side, which is what a cast shadow does.
  const keyGrown = dilate(face, cols, rows, o.keyline);
  const [odx, ody] = o.keylineOffset;
  const key =
    odx === 0 && ody === 0
      ? keyGrown
      : (() => {
          const out = new Uint8Array(cols * rows);
          for (let y = 0; y < rows; y++) {
            const sy = y - ody;
            if (sy < 0 || sy >= rows) continue;
            for (let x = 0; x < cols; x++) {
              const sx = x - odx;
              if (sx < 0 || sx >= cols) continue;
              out[y * cols + x] = keyGrown[sy * cols + sx];
            }
          }
          // The letter must still be fully enclosed: a pure translation leaves
          // the trailing edge bare, so the shadow reads as a shape sitting
          // BESIDE the letter rather than behind it. Union with the unshifted
          // grow so the keyline still rings the glyph, just heavier on one side.
          for (let i = 0; i < out.length; i++) if (keyGrown[i]) out[i] = 1;
          return out;
        })();

  // ── 4. pack and scale up with NO smoothing ────────────────────────────────
  // imageSmoothingEnabled = false is the entire reason this stays blocky: with
  // it on the browser bilinearly interpolates and hands back exactly the soft
  // ramp the effect is trying to avoid.
  const packLow = document.createElement("canvas");
  packLow.width = cols;
  packLow.height = rows;
  const px = packLow.getContext("2d")!;
  const img = px.createImageData(cols, rows);
  for (let c = 0; c < cols * rows; c++) {
    img.data[c * 4] = face[c] ? 255 : 0;
    img.data[c * 4 + 1] = halo[c] ? 255 : 0;
    img.data[c * 4 + 2] = key[c] ? 255 : 0;
    img.data[c * 4 + 3] = 255;
  }
  px.putImageData(img, 0, 0);

  const out = document.createElement("canvas");
  out.width = W;
  out.height = H;
  const ox = out.getContext("2d")!;
  ox.imageSmoothingEnabled = false;
  ox.drawImage(packLow, 0, 0, W, H);
  return out;
}

```

### arcade/shaders.ts
```ts
// Arcade pixel type — the shader.
//
// The geometry is already decided by the time we get here: the mask carries the
// letter face, the white halo and the black keyline as three channels, all
// quantised on the same low-res grid (see text-mask.ts). This pass paints them,
// then puts the poster's surface on top.
//
// WHAT THE SOURCE PSD ACTUALLY CONTAINS, since two earlier builds got this wrong
// by reading the layer names instead of the pixels:
//
//   Background            solid #EB0809
//   Effect/Layer          the word, as a smart object that is ALREADY PIXELATED
//                         (a 150x39 bitmap scaled 25x, measured), carrying two
//                         outside strokes: 73px white, then 103px #1F1E1C
//   Effect/Threshold 150  in COLOR BURN — crushes the leftover antialiasing,
//                         it is NOT what makes the blocks
//   Textures/             two gradients, Noise (hard light 30%), Dust (screen
//                         20%), Noise (linear light 70%)
//
// THE TEXTURES ARE REAL IMAGES, NOT PROCEDURAL NOISE, and this is worth stating
// because generating them was the obvious shortcut and it looks nothing like the
// source. Pulling the actual layer pixels out of the PSD:
//   - the "Noise" on HARD LIGHT is a MOIRE INTERFERENCE PATTERN — two curved
//     line gratings beating against each other (measured: ~28.5px at ~28deg and
//     ~41px at ~4deg, and the angle swings across the frame, so the gratings are
//     genuinely curved rather than straight). It is the thing that gives the
//     flat ground its woven, screen-printed shimmer.
//   - the "Noise" on LINEAR LIGHT is fine per-pixel monochrome grain.
//   - "Dust" on SCREEN is sparse bright specks (mean 9/255).
// The moire in particular cannot be faked with a hash: it is structured, and a
// random field in its place reads as television static, which is exactly how the
// first build looked.
//   Settings/             Vibrance +20, Levels in 5..230 gamma 1.00
//
// The two things worth keeping in mind: the ground SURVIVES (the threshold sits
// on a smart object only as big as the word, so there is nothing to burn out on
// the open poster — burning the whole frame drives every pixel to black), and
// the blocks come from RESOLUTION, never from thresholding a blur.

export const FULL_VERT = `
attribute vec2 aPosition;
varying vec2 vUv;
void main() {
  vUv = aPosition * 0.5 + 0.5;
  gl_Position = vec4(aPosition, 0.0, 1.0);
}
`;

export const ARCADE_FRAG = `
// highp requested, but plenty of mobile GPUs quietly hand back mediump in the
// fragment stage. Every hash folds its input into a small range first, so the
// texture survives on a phone instead of collapsing into flat bands.
precision highp float;

varying vec2 vUv;

uniform sampler2D uField;       // R = face, G = white halo, B = black keyline
uniform vec2  uResolution;
uniform float uAspect;
uniform float uTime;

uniform sampler2D uMoire;   // the real moire interference plate, tiled
uniform sampler2D uGrain;   // the real fine-grain plate
uniform sampler2D uDust;    // the real dust specks, tiled
uniform float uTexture;     // surface strength
uniform float uSeparation;  // per-channel moire offset — print misregistration
uniform vec2  uCursor;      // pointer in uv
uniform float uCursorOn;    // eased presence, 0..1
uniform float uSwim;        // S3: cursor-driven counter-rotation of the moire
uniform float uParallax;    // how far the plates drift toward the pointer, in uv
uniform float uPull;        // magnetism: how hard the whole word leans in
uniform vec2  uMoireScale;  // how many times the plate tiles across the card
uniform vec2  uGrainScale;
uniform vec2  uDustScale;
uniform float uThreshold;   // the source's Threshold, as a crush on the ground

uniform vec3  uGround;
uniform vec3  uInk;
uniform vec3  uPaper;
uniform vec3  uFringe;

uniform vec2  uLevels;      // input black / white points
uniform float uVibrance;

// Still needed for the final dither only — the surface itself is now sampled
// from the real PSD plates rather than generated.
float hash(vec2 p){
  p = mod(p, 137.0);
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
// ── Photoshop blend modes, written out exactly ──────────────────────────────
// Not approximated with a mix(): these curves are the difference between
// "looks a bit like the reference" and "is the reference".
float hardLight(float base, float blend){
  return blend < 0.5
    ? 2.0 * base * blend
    : 1.0 - 2.0 * (1.0 - base) * (1.0 - blend);
}
float linearLight(float base, float blend){
  return clamp(base + 2.0 * blend - 1.0, 0.0, 1.0);
}

// Levels with gamma 1.0 — the source has no midtone shift, so this is a pure
// contrast crush and there is no pow() to pay for.
vec3 levels(vec3 c, float lo, float hi){
  return clamp((c - lo) / max(hi - lo, 0.0001), 0.0, 1.0);
}

// Vibrance, not saturation: it lifts muted channels far harder than saturated
// ones, so a hot ground gains almost nothing while the fringe gains a lot. Plain
// saturation clips the ground to a flat primary and kills the printed quality.
vec3 vibrance(vec3 c, float amt){
  float mx = max(c.r, max(c.g, c.b));
  float mn = min(c.r, min(c.g, c.b));
  float sat = mx - mn;
  float lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
  return mix(vec3(lum), c, 1.0 + amt * (1.0 - sat));
}

void main() {
  vec2 uv = vUv;
  vec2 p = uv * vec2(uAspect, 1.0);

  // ── PARALLAX ──────────────────────────────────────────────────────────────
  // The three plates drift toward the pointer at DIFFERENT rates, so the letter,
  // its white halo and its shadow separate slightly and the type reads as a
  // stack of layers rather than one flat print.
  //
  // THE UV IS SHIFTED, THE MASK IS NOT REBUILT, and that distinction is the
  // whole reason this can be smooth. The letters live on a fixed lattice, so
  // moving them WITHIN the grid means either resampling the block edges
  // (soft, shimmering staircase) or snapping in whole 9px cells (a visible
  // lurch). Sliding the sample point instead moves the entire rasterised plate
  // as one rigid sheet: every block keeps the exact shape it was built with, and
  // the motion is free of the grid because nothing is re-quantised.
  //
  // Tiny on purpose — a couple of pixels at most. Enough that the layers breathe
  // when you move; small enough that the poster never looks like it is sliding.
  // MAGNETISM. The word as a whole leans toward the pointer, strongest when the
  // cursor is near it and relaxing as you move away — so it reads as attraction
  // rather than as the card tracking your mouse everywhere.
  //
  // Measured from the FRAME CENTRE, not per pixel: a per-pixel pull would warp
  // the letterforms (near cells dragged further than far ones), and warping is
  // the one thing a lattice cannot survive. One vector for the whole plate keeps
  // every block rigid and just moves the sheet.
  vec2 fromMid = uCursor - vec2(0.5, 0.5);
  float grip = 1.0 - smoothstep(0.0, 0.75, length(fromMid * vec2(uAspect, 1.0)));
  vec2 pull = fromMid * uPull * grip * uCursorOn;

  vec2 toCur = (uCursor - uv) * uCursorOn * uParallax + pull;
  // The shadow moves most, the face least: the further a layer is from the
  // surface, the more a viewpoint change should shift it.
  float face = texture2D(uField, uv - toCur * 0.35).r;
  float halo = texture2D(uField, uv - toCur * 0.70).g;
  float key  = texture2D(uField, uv - toCur * 1.00).b;

  // ── THE GROUND SURVIVES ───────────────────────────────────────────────────
  vec3 col = uGround;

  // ── THE THRESHOLD, doing its real job ─────────────────────────────────────
  // In the source this is a Threshold 150 in color burn, and it only deepens the
  // ground where the print is already dark — it does not create the letterforms.
  // Modelled as a mild darkening of the ground's own shadows.
  float lum = dot(col, vec3(0.2126, 0.7152, 0.0722));
  col *= mix(1.0, 0.86, smoothstep(uThreshold, uThreshold - 0.35, lum));

  // ── THE LETTERS, painted back to front ────────────────────────────────────
  // Widest first, exactly as the two stroke effects stack: the black keyline is
  // 103px where the white halo is 73px, so the black shows as a band beyond the
  // white rather than sitting under it.
  col = mix(col, uInk, key);
  col = mix(col, uPaper, halo);
  col = mix(col, uInk, face);

  // ── THE SURFACE, OVER EVERYTHING ──────────────────────────────────────────
  // The three real texture layers from the PSD, in their own blend modes.
  //
  // THIS RUNS AFTER THE LETTERS, and the order is not a detail. In the source
  // the Textures group sits ABOVE the Effect group, so the surface falls across
  // the whole poster — the white halo and the black keyline included. Applying
  // it to the ground and then painting clean type on top leaves the letters
  // looking like flat vector laid on a printed background, and the most obvious
  // tell in the reference is that the white faces are just as printed as the red.
  //
  // The plates are sampled at a scale that keeps the moire's measured pitch
  // (~28px at 4500 wide, so ~0.6% of the width) rather than stretching the image
  // to fit — stretch it and the interference pattern changes frequency, which is
  // the one property that makes it read as moire at all.
  // ── S3 — THE MOIRE SWIMS ──────────────────────────────────────────────────
  // The plate is TWO curved gratings beating against each other, and that is
  // what makes this worth doing: interference is enormously sensitive to the
  // angle between its gratings. Sampling the plate a second time, rotated by a
  // fraction of a degree around the cursor, and taking the darker of the two
  // shifts the beat pattern across the whole surface. A rotation far too small
  // to see as a rotation produces a visible change in where the bands land.
  //
  // Rotating the WHOLE surface instead would just spin the texture, which reads
  // as the poster turning. Beating two nearly-identical samples is what makes
  // the pattern itself move while the print stays put.
  vec2 mUV = uv * uMoireScale;
  vec2 dUV = uv * uDustScale;

  float swimD = length((uv - uCursor) * vec2(uAspect, 1.0));
  // Falls off with distance, so the surface churns under your hand and is still
  // out at the edges — a local disturbance rather than a global animation.
  // A TIGHT falloff. At 0.55 the disturbance covered most of the card, which is
  // what made it read as a shape following the cursor rather than as the surface
  // reacting where you touch it.
  float swimAmt = smoothstep(0.22, 0.0, swimD) * uCursorOn * uSwim;
  float a = swimAmt * 0.055;                       // radians: ~3 degrees at most
  float cs = cos(a), sn = sin(a);
  vec2 about = uCursor * uMoireScale;
  vec2 rel = mUV - about;
  vec2 mUV2 = about + vec2(rel.x * cs - rel.y * sn, rel.x * sn + rel.y * cs);
  // ── COLOUR SEPARATION ─────────────────────────────────────────────────────
  // The moire sampled three times, one per channel, each offset by a fraction of
  // a tile along the stripe direction. A print out of register — and because the
  // offset is tied to the plate rather than the screen, the fringe rides the
  // interference pattern instead of floating over it.
  //
  // Small: at more than a texel or two this stops reading as registration drift
  // and starts reading as a 3D-glasses effect.
  vec2 sep = vec2(0.0022, 0.0009) * uSeparation;
  // BLEND BETWEEN THE TWO SAMPLES — DO NOT take min() of them.
  //
  // min() was the first attempt, on the reasoning that two overlaid physical
  // screens both block light. That reasoning is wrong in a way that is obvious
  // in hindsight: the minimum of two draws from the same distribution is
  // systematically DARKER than either draw (about 0.05 lower for this plate), so
  // it does not merely move the interference, it drops the brightness of the
  // whole region. With a wide falloff that paints a large dark disc under the
  // pointer — a grey blob that has nothing to do with moire.
  //
  // Mixing keeps the mean exactly where it was. The rotated sample still shifts
  // where the bands fall, which is the entire effect, but the surface does not
  // get darker for being disturbed.
  float mA = texture2D(uMoire, mUV).r;
  float mB = texture2D(uMoire, mUV2).r;
  float moire = mix(mA, mB, swimAmt);
  vec3 moireRGB = vec3(
    mix(texture2D(uMoire, mUV + sep).r, texture2D(uMoire, mUV2 + sep).r, swimAmt),
    moire,
    mix(texture2D(uMoire, mUV - sep).r, texture2D(uMoire, mUV2 - sep).r, swimAmt)
  );
  // The fine grain is its OWN plate from the PSD (the linear-light layer), not a
  // resample of the moire. They are genuinely different images — one is
  // structured interference, the other is per-pixel noise — and standing one in
  // for the other was a shortcut that showed.
  float grain = texture2D(uGrain, uv * uGrainScale).r;
  float dust  = texture2D(uDust, dUV).r;

  float t = uTexture;
  // 1) the moire, HARD LIGHT. The PSD layer is 30% opacity, but that is 30% of a
  //    plate composited in Photoshop's own colour pipeline — here it sits over a
  //    fully saturated ground where a 30% mix only swings the value by ~0.07,
  //    which is on the edge of visible at 8-bit. Taken up to where the print
  //    actually reads.
  vec3 h1 = vec3(
    hardLight(col.r, moireRGB.r),
    hardLight(col.g, moireRGB.g),
    hardLight(col.b, moireRGB.b)
  );
  col = mix(col, h1, 0.72 * t);

  // 2) the fine grain, LINEAR LIGHT.
  vec3 l2 = vec3(linearLight(col.r, grain), linearLight(col.g, grain), linearLight(col.b, grain));
  col = mix(col, l2, 0.55 * t);

  // A SIGNED GRAIN on top, because both blend modes go nearly silent on white.
  // Hard light and linear light CLIP at 1.0, so on a white letter face every
  // value above 0.5 does nothing and the paper can only ever darken — the grain
  // lands about half as strong on the type as on the ground, which is the
  // opposite of the reference, where the white faces are the most obviously
  // printed part of the poster.
  col += (grain - 0.5) * 0.16 * t;
  col += (moire - 0.5) * 0.10 * t;

  // 3) the dust, SCREEN
  col = 1.0 - (1.0 - col) * (1.0 - dust * 0.45 * t);

  // ── EDGE FRINGE ───────────────────────────────────────────────────────────
  // The coloured speckle the source shows along the quantised boundary — a
  // registration artefact of the print. Confined to the band between the halo
  // and the face, so it clings to the block edges rather than dusting the frame.
  float rim = clamp(halo - face, 0.0, 1.0);
  float speck = step(0.62, hash(floor(p * uResolution.y * 0.6) + 3.0));
  col += uFringe * rim * speck * 0.22 * uTexture;

  // ── FINISH ────────────────────────────────────────────────────────────────
  col = levels(col, uLevels.x, uLevels.y);
  col = vibrance(col, uVibrance);
  col += (hash(uv * 1024.0 + fract(uTime)) - 0.5) * (1.5 / 255.0);

  gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}
`;

```

### arcade/engine.ts
```ts
"use client";

// Arcade pixel type — the engine.
//
// Framework-free WebGL1 class: owns the context, the mask upload, the rAF loop
// and the eased pointer. React never touches a uniform. Same shape as the chrome
// and chroma engines, and forked from their lifecycle: idle-deferred CPU build,
// pause offscreen, reveal-on-first-paint so the card never flashes an empty
// canvas.

import { mediaUrl } from "../../lib/video-sources";
import { ARCADE_FRAG, FULL_VERT } from "./shaders";
import { makeArcadeField } from "./text-mask";
import {
  COLORWAYS,
  DEFAULTS,
  FONT_CSS,
  LEVELS_IN,
  VIBRANCE,
  type ArcadeParams,
  type Colorway,
} from "./params";
import {
  SEQUENCE,
  cellsAt,
  nextPhase,
  phaseMs,
  type Phase,
} from "./cycle";

/** Flatten a colourway to 12 floats so two of them can be lerped as plain
 *  numbers rather than re-parsed every frame. */
function toFloats(c: Colorway): number[] {
  return [...c.ground, ...c.ink, ...c.paper, ...c.fringe];
}

const UNIFORMS = [
  "uField", "uResolution", "uAspect", "uTime",
  "uThreshold", "uTexture", "uMoire", "uGrain", "uDust", "uSeparation", "uCursor", "uCursorOn", "uSwim", "uParallax", "uPull",
  "uMoireScale", "uGrainScale", "uDustScale",
  "uGround", "uInk", "uPaper", "uFringe",
  "uLevels", "uVibrance",
] as const;

export class Arcade {
  private host: HTMLElement;
  private canvas: HTMLCanvasElement;
  private gl: WebGLRenderingContext | null = null;
  private prog: WebGLProgram | null = null;
  private loc: Record<string, WebGLUniformLocation | null> = {};
  private quad: WebGLBuffer | null = null;
  private tex: WebGLTexture | null = null;

  /** The two real texture plates lifted out of the PSD. */
  private moire: WebGLTexture | null = null;
  private grain: WebGLTexture | null = null;
  private dust: WebGLTexture | null = null;

  readonly params: ArcadeParams = { ...DEFAULTS };

  private w = 0;
  private h = 0;
  private dpr = 1;
  private fontFamily = `${FONT_CSS}, sans-serif`;

  /** The live colourway.
   *
   *  NOT crossfaded any more. The resolution collapse IS the transition, and a
   *  smooth colour fade running at the same time is a second transition fighting
   *  the first — you end up watching a wash instead of a collapse. The colour now
   *  switches instantly at the coarsest frame, where the field is a handful of
   *  huge squares and there is nothing legible for the change to interrupt. */
  private cur = toFloats(COLORWAYS[0]);

  /** The autonomous resolution cycle. Only the card runs it; the playground
   *  stays wherever you put the sliders. */
  /** Pointer in uv, its eased presence, and the eased shadow offset.
   *
   *  THE SHADOW IS EASED AS A FLOAT AND USED AS AN INTEGER. The keyline lives on
   *  the lattice, so its offset has to be whole cells or the shadow lands half a
   *  block out of register and the whole point of the grid is lost. But snapping
   *  the RAW pointer to cells makes the shadow jump the instant you cross a cell
   *  boundary, which reads as a glitch. Easing a float and rounding only at the
   *  moment of use gives a shadow that moves in clean whole-cell steps at a pace
   *  the eye reads as weight. */
  private cx = 0.5;
  private cy = 0.5;
  private on = 0;
  private onTarget = 0;
  private shX = 0;
  private shY = 0;

  private cycling = false;
  private phase: Phase = "hold";
  private phaseT = 0;
  private idx = 0;
  private wordIdx = 0;
  /** The cell count currently uploaded, so the frame loop only rebuilds the mask
   *  when the QUANTISED value actually changes. Tweening 150 -> 16 continuously
   *  would rebuild ~60x a second for maybe 40 visually distinct steps. */
  private builtCols = -1;
  /** The keyline offset actually baked into the current mask, in whole cells. */
  private liveShadow: [number, number] = [0, 0];

  private raf = 0;
  private running = false;
  private awake = false;
  private painted = false;
  private destroyed = false;
  private t0 = performance.now();
  private last = 0;

  private builtW = 0;
  private builtH = 0;
  private builtWord = "";
  private builtKey = "";
  private builtFont = "";
  private buildScheduled = 0;

  ok = false;

  constructor(host: HTMLElement, fontFamily?: string) {
    this.host = host;
    if (fontFamily) this.fontFamily = fontFamily;
    this.canvas = document.createElement("canvas");
    Object.assign(this.canvas.style, {
      position: "absolute",
      inset: "0",
      width: "100%",
      height: "100%",
      display: "block",
      opacity: "0",
    });
    host.appendChild(this.canvas);

    const gl = this.canvas.getContext("webgl", {
      alpha: false,
      antialias: false,
      premultipliedAlpha: false,
    });
    if (!gl) return;
    this.gl = gl;

    try {
      this.prog = this.build(FULL_VERT, ARCADE_FRAG);
    } catch {
      this.gl = null;
      return;
    }
    for (const u of UNIFORMS) {
      this.loc[u] = gl.getUniformLocation(this.prog, u);
    }

    this.quad = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([-1, -1, 3, -1, -1, 3]),
      gl.STATIC_DRAW,
    );
    const aPos = gl.getAttribLocation(this.prog, "aPosition");
    gl.useProgram(this.prog);
    gl.enableVertexAttribArray(aPos);
    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

    // The two real PSD plates. Served from /public in dev and R2 in prod via
    // mediaUrl(), the same as the grunge scans and the holo photo.
    this.loadPlate(mediaUrl("/vault/arcade-moire.webp"), "moire");
    this.loadPlate(mediaUrl("/vault/arcade-grain.webp"), "grain");
    this.loadPlate(mediaUrl("/vault/arcade-dust.webp"), "dust");

    this.resize();
    void this.buildFieldNow();

    this.canvas.addEventListener("pointermove", this.onMove);
    this.canvas.addEventListener("pointerleave", this.onLeave);

    this.ok = true;
  }

  /** Load one of the PSD plates and upload it as a REPEATing texture.
   *
   *  gl.REPEAT is the whole reason these are power-of-two-friendly and sampled
   *  by scale rather than stretched to the card: tiling preserves the moire's
   *  measured pitch, where fitting the image to the frame would change its
   *  frequency and stop it reading as interference. */
  private loadPlate(url: string, onto: "moire" | "grain" | "dust") {
    const gl = this.gl;
    if (!gl) return;
    const img = new Image();
    // R2 is a different origin, and an un-CORS'd image taints the context the
    // moment it is uploaded as a texture.
    img.crossOrigin = "anonymous";
    img.onload = () => {
      if (!this.gl || this.destroyed) return;
      // WEBGL1 REQUIRES POWER-OF-TWO FOR gl.REPEAT, and this is the trap that
      // silently blanked the whole surface once already: a NPOT texture with
      // REPEAT wrapping is INCOMPLETE, so texture2D() returns solid black with
      // no warning, no console error and no visible failure — the card just
      // renders a flat ground and looks like the texture "did not load".
      // The plates are exported 512x512 for exactly this reason; fall back to
      // CLAMP if one ever is not, so a bad asset degrades instead of vanishing.
      const pot = (n: number) => (n & (n - 1)) === 0;
      const repeatOk = pot(img.width) && pot(img.height);
      const t = gl.createTexture();
      gl.bindTexture(gl.TEXTURE_2D, t);
      const wrap = repeatOk ? gl.REPEAT : gl.CLAMP_TO_EDGE;
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
      this[onto] = t;
      // The plates arrive after the first paint, so redraw once they land or the
      // card sits there flat until something else happens to wake it.
      if (!this.running) this.render();
    };
    img.src = url;
  }

  private build(vs: string, fs: string): WebGLProgram {
    const gl = this.gl!;
    const c = (type: number, src: string) => {
      const sh = gl.createShader(type)!;
      gl.shaderSource(sh, src);
      gl.compileShader(sh);
      if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
        throw new Error(gl.getShaderInfoLog(sh) || "compile failed");
      }
      return sh;
    };
    const prog = gl.createProgram()!;
    gl.attachShader(prog, c(gl.VERTEX_SHADER, vs));
    gl.attachShader(prog, c(gl.FRAGMENT_SHADER, fs));
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
      throw new Error(gl.getProgramInfoLog(prog) || "link failed");
    }
    return prog;
  }

  private onMove = (e: PointerEvent) => {
    const r = this.canvas.getBoundingClientRect();
    this.cx = (e.clientX - r.left) / r.width;
    this.cy = 1 - (e.clientY - r.top) / r.height; // uv origin is bottom-left
    this.onTarget = 1;
    this.wake();
  };

  private onLeave = () => {
    this.onTarget = 0;
    this.wake();
  };

  private wake() {
    if (this.awake && !this.running) this.start();
    else if (!this.running) this.render();
  }

  setParams(p: Partial<ArcadeParams>) {
    // The mask bakes the grid, the strokes and the slant, so any of those four
    // needs a rebuild; everything else is a pure uniform change.
    const rebuild =
      (p.word !== undefined && p.word !== this.params.word) ||
      (p.cols !== undefined && p.cols !== this.params.cols) ||
      (p.halo !== undefined && p.halo !== this.params.halo) ||
      (p.keyline !== undefined && p.keyline !== this.params.keyline) ||
      (p.keylineOffset !== undefined &&
        p.keylineOffset.join() !== this.params.keylineOffset.join()) ||
      (p.italic !== undefined && p.italic !== this.params.italic);
    Object.assign(this.params, p);
    if (p.colorway !== undefined) this.setColorway(p.colorway);
    // Only the word and the RAMP need a new mask — the ramp is baked into the
    // blur, so it is the one slider that cannot be a pure uniform change.
    if (rebuild) this.scheduleBuild();
    this.wake();
  }

  /** Switch colourway. Instant — see the note on `cur`. */
  setColorway(i: number) {
    const n = ((i % COLORWAYS.length) + COLORWAYS.length) % COLORWAYS.length;
    this.idx = n;
    this.params.colorway = n;
    this.cur = toFloats(COLORWAYS[n]);
    this.wake();
  }

  /** The gallery card runs the resolution cycle; the playground does not. */
  setCycling(on: boolean) {
    this.cycling = on;
    this.phase = "hold";
    this.phaseT = 0;
  }

  setFont(family: string) {
    if (family === this.fontFamily) return;
    this.fontFamily = family;
    this.scheduleBuild();
  }

  resize() {
    const r = this.host.getBoundingClientRect();
    this.dpr = Math.min(2, window.devicePixelRatio || 1);
    this.w = r.width;
    this.h = r.height;
    const cw = Math.max(1, Math.round(this.w * this.dpr));
    const ch = Math.max(1, Math.round(this.h * this.dpr));
    if (this.canvas.width !== cw || this.canvas.height !== ch) {
      this.canvas.width = cw;
      this.canvas.height = ch;
      this.gl?.viewport(0, 0, cw, ch);
      this.scheduleBuild();
    }
  }

  /** Everything the mask depends on, as one comparable string. */
  private paramKey(): string {
    const p = this.params;
    const sh = p.magnet ? this.liveShadow : p.keylineOffset;
    return `${p.word}|${p.cols}|${p.halo}|${p.keyline}|${sh.join(",")}|${p.italic}`;
  }

  private maskSize(): [number, number] {
    const MAX_W = 1600;
    const mw = Math.max(2, Math.min(MAX_W, Math.round(this.w * this.dpr)));
    const mh = Math.max(2, Math.round(mw * (this.h / Math.max(1, this.w))));
    return [mw, mh];
  }

  private scheduleBuild() {
    if (!this.gl || this.destroyed) return;
    const [mw, mh] = this.maskSize();
    if (
      mw === this.builtW &&
      mh === this.builtH &&
      this.params.word === this.builtWord &&
      this.builtKey === this.paramKey() &&
      this.fontFamily === this.builtFont
    ) {
      return;
    }
    if (this.buildScheduled) return;
    const run = () => {
      this.buildScheduled = 0;
      void this.buildFieldNow();
    };
    const ric = (
      window as unknown as {
        requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number;
      }
    ).requestIdleCallback;
    this.buildScheduled = ric ? ric(run, { timeout: 200 }) : window.setTimeout(run, 0);
  }

  /** Rebuild + upload RIGHT NOW, skipping the idle defer and the dirty check.
   *
   *  The animation drives `cols` frame by frame, so it needs the mask to land in
   *  the same frame it asked for it. scheduleBuild() is still the right path for
   *  word/font/resize changes, where the work is genuinely deferrable and
   *  arriving a beat late costs nothing. */
  buildSync() {
    const gl = this.gl;
    if (!gl || this.destroyed) return;
    const [mw, mh] = this.maskSize();
    this.uploadMask(mw, mh);
  }

  /** Rasterise at the current params and hand the result to GL. */
  private uploadMask(mw: number, mh: number) {
    const gl = this.gl;
    if (!gl || this.destroyed) return;
    this.builtW = mw;
    this.builtH = mh;
    this.builtWord = this.params.word;
    this.builtKey = this.paramKey();
    this.builtFont = this.fontFamily;

    const art = makeArcadeField({
      word: this.params.word,
      cols: this.params.cols,
      halo: this.params.halo,
      keyline: this.params.keyline,
      keylineOffset: this.params.magnet
        ? this.liveShadow
        : this.params.keylineOffset,
      italic: this.params.italic,
      w: mw,
      h: mh,
      fontFamily: this.fontFamily,
    });
    if (!this.tex) this.tex = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, this.tex);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
    gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, art);
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
  }

  private async buildFieldNow() {
    const gl = this.gl;
    if (!gl || this.destroyed) return;
    if (this.buildScheduled) {
      const cic = (window as unknown as { cancelIdleCallback?: (id: number) => void })
        .cancelIdleCallback;
      if (cic) cic(this.buildScheduled);
      else window.clearTimeout(this.buildScheduled);
      this.buildScheduled = 0;
    }
    const [mw, mh] = this.maskSize();
    this.uploadMask(mw, mh);
    if (!this.running) this.render();
  }

  start() {
    if (!this.ok) return;
    this.awake = true;
    if (this.running) return;
    this.running = true;
    this.last = 0;
    this.resize();
    const loop = (now: number) => {
      if (!this.running) return;
      this.frame(now);
      this.raf = requestAnimationFrame(loop);
    };
    this.raf = requestAnimationFrame(loop);
  }

  stop() {
    this.awake = false;
    this.pause();
  }

  private pause() {
    this.running = false;
    if (this.raf) cancelAnimationFrame(this.raf);
    this.raf = 0;
  }

  private frame(now: number) {
    const dt = this.last ? Math.min(64, now - this.last) : 16;
    this.last = now;

    this.on += (this.onTarget - this.on) * 0.12;
    this.stepMagnet();

    if (this.cycling) this.stepCycle(dt);

    this.render();
  }

  /**
   * C2 — THE MAGNETIC KEYLINE.
   *
   * The drop shadow follows the pointer, so the light source appears to track
   * your hand and the letters read as lifting off the poster. Only the shadow
   * moves: the face and the halo stay welded to the lattice, which is what keeps
   * this feeling like a light moving rather than the type sliding around.
   *
   * The offset is INVERTED — the shadow falls away from the cursor, because that
   * is where a light in front of the card would throw it.
   */
  private stepMagnet() {
    const p = this.params;
    if (!p.magnet) return;
    const reach = p.keylineOffset[0] + p.magnetReach;
    const tx = -(this.cx - 0.5) * 2 * reach * this.on;
    const ty = (this.cy - 0.5) * 2 * reach * this.on;
    this.shX += (tx - this.shX) * 0.14;
    this.shY += (ty - this.shY) * 0.14;

    // Round to whole cells only at the point of use, and only rebuild when the
    // integer actually changes — otherwise this would rasterise every frame to
    // move the shadow by a fraction of a block nobody can see.
    const base = p.keylineOffset;
    const nx = Math.round(base[0] + this.shX);
    const ny = Math.round(base[1] + this.shY);
    if (nx !== this.liveShadow[0] || ny !== this.liveShadow[1]) {
      this.liveShadow = [nx, ny];
      this.buildSync();
    }
  }

  /** Advance the resolution cycle and rebuild the mask when the grid changes. */
  private stepCycle(dt: number) {
    this.phaseT += dt;
    const dur = phaseMs(this.phase);

    if (this.phaseT >= dur) {
      this.phaseT = 0;
      const prev = this.phase;
      this.phase = nextPhase(this.phase);
      // THE SWAP HAPPENS AT THE BOTTOM. Between collapse and resolve the field
      // is ~16 cells across and completely illegible, so changing the word and
      // the colour here costs nothing visually — the new word simply resolves
      // out of the same squares the old one dissolved into.
      if (prev === "collapse") {
        this.wordIdx = (this.wordIdx + 1) % SEQUENCE.length;
        this.params.word = SEQUENCE[this.wordIdx];
        this.setColorway(this.idx + 1);
      }
    }

    const t = Math.min(1, this.phaseT / phaseMs(this.phase));
    const cols = cellsAt(this.phase, t);
    if (cols !== this.builtCols) {
      this.builtCols = cols;
      this.params.cols = cols;
      this.buildSync();
    }
  }

  renderStill() {
    this.resize();
    void this.buildFieldNow();

    this.canvas.addEventListener("pointermove", this.onMove);
    this.canvas.addEventListener("pointerleave", this.onLeave);
    this.render();
  }

  private render() {
    const gl = this.gl;
    if (!gl || !this.prog || !this.tex) return;
    const p = this.params;

    gl.useProgram(this.prog);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, this.tex);
    gl.uniform1i(this.loc.uField, 0);
    gl.uniform2f(this.loc.uResolution, this.canvas.width, this.canvas.height);
    gl.uniform1f(this.loc.uAspect, this.w / Math.max(1, this.h));
    gl.uniform1f(this.loc.uTime, (performance.now() - this.t0) / 1000);

    gl.uniform1f(this.loc.uThreshold, p.threshold);

    // ── PLATE SCALE ───────────────────────────────────────────────────────
    // How big the printed surface reads, and the number that has been wrong in
    // both directions already.
    //
    // Tiling the plate 7.5x across the card crushed its ~28px interference pitch
    // to ~1.1px on screen — sub-pixel, so it aliased to flat grey and the whole
    // poster looked like static. Showing it at 1:1 fixed that but left the
    // pattern fine enough to need looking for.
    //
    // The moire plate is now a 1024px crop taken at 1:1 from the source, so its
    // pitch is native. Showing ONE tile across a ~1344px card magnifies it only
    // ~1.3x and lands the pitch near 36px — big enough to read across the room
    // without the texel softness that magnifying a small plate would bring.
    const MOIRE_TILES = 1.0;
    const DUST_TILES = 0.85;
    gl.activeTexture(gl.TEXTURE1);
    gl.bindTexture(gl.TEXTURE_2D, this.moire);
    gl.uniform1i(this.loc.uMoire, 1);
    gl.activeTexture(gl.TEXTURE2);
    gl.bindTexture(gl.TEXTURE_2D, this.grain);
    gl.uniform1i(this.loc.uGrain, 2);
    gl.activeTexture(gl.TEXTURE3);
    gl.bindTexture(gl.TEXTURE_2D, this.dust);
    gl.uniform1i(this.loc.uDust, 3);
    // The plates are SQUARE, so scaling x by the aspect keeps them unstretched
    // on a 1344x620 card — a stretched moire shears into stripes and stops
    // reading as interference at all.
    const aspect = this.w / Math.max(1, this.h);
    gl.uniform2f(this.loc.uMoireScale, MOIRE_TILES * aspect, MOIRE_TILES);
    // The grain stays FINE. It is per-pixel noise, so unlike the moire it gains
    // nothing from being enlarged — blown up it stops being grain and becomes
    // blotches. Tiled hard so it keeps a tight, printed tooth.
    gl.uniform2f(this.loc.uGrainScale, aspect * 2.6, 2.6);
    gl.uniform2f(this.loc.uDustScale, DUST_TILES * aspect, DUST_TILES);

    gl.uniform1f(this.loc.uTexture, p.texture);
    gl.uniform1f(this.loc.uSeparation, p.separation);
    gl.uniform2f(this.loc.uCursor, this.cx, this.cy);
    gl.uniform1f(this.loc.uCursorOn, this.on);
    gl.uniform1f(this.loc.uSwim, p.swim);
    gl.uniform1f(this.loc.uParallax, p.parallax);
    gl.uniform1f(this.loc.uPull, p.pull);

    const c = this.cur;
    gl.uniform3f(this.loc.uGround, c[0], c[1], c[2]);
    gl.uniform3f(this.loc.uInk, c[3], c[4], c[5]);
    gl.uniform3f(this.loc.uPaper, c[6], c[7], c[8]);
    gl.uniform3f(this.loc.uFringe, c[9], c[10], c[11]);


    gl.uniform2f(this.loc.uLevels, LEVELS_IN[0], LEVELS_IN[1]);
    gl.uniform1f(this.loc.uVibrance, VIBRANCE);

    gl.drawArrays(gl.TRIANGLES, 0, 3);

    if (!this.painted) {
      this.painted = true;
      this.canvas.style.opacity = "1";
    }

    // Idle out once nothing is left to animate. The card is a static poster
    // between colourway changes, so there is no reason to hold a rAF open.
    // Idle out once nothing is left to animate. While cycling there always is,
    // so this only fires for a card that is parked and not being hovered.
    if (
      !this.awake &&
      !this.cycling &&
      Math.abs(this.on - this.onTarget) < 0.002
    ) {
      this.pause();
    }
  }

  destroy() {
    this.destroyed = true;
    if (this.buildScheduled) {
      const cic = (window as unknown as { cancelIdleCallback?: (id: number) => void })
        .cancelIdleCallback;
      if (cic) cic(this.buildScheduled);
      else window.clearTimeout(this.buildScheduled);
      this.buildScheduled = 0;
    }
    this.stop();
    this.canvas.removeEventListener("pointermove", this.onMove);
    this.canvas.removeEventListener("pointerleave", this.onLeave);
    const gl = this.gl;
    if (gl) {
      if (this.tex) gl.deleteTexture(this.tex);
      if (this.moire) gl.deleteTexture(this.moire);
      if (this.grain) gl.deleteTexture(this.grain);
      if (this.dust) gl.deleteTexture(this.dust);
      if (this.quad) gl.deleteBuffer(this.quad);
      gl.getExtension("WEBGL_lose_context")?.loseContext();
    }
    this.canvas.remove();
  }
}

```

### arcade/playground.tsx
```tsx
"use client";

// The playground: the same poster, with the two numbers that ARE the effect
// exposed as sliders.
//
// THRESHOLD and RAMP are the whole thing, and they are worth understanding as a
// pair. The ramp is the blur baked into the mask, so it decides how much room
// the edge has to cross the cut — that is the BLOCK SIZE. The threshold decides
// WHERE along that ramp the cut lands, so it slides the staircase in and out and
// the letters gain or lose weight in whole blocks. Neither is a "style" control:
// between them they are the effect.
//
// Everything else here (word, colourway, texture) is dressing on top, which is
// why they sit below the two that matter.

import { useCallback, useEffect, useRef, useState } from "react";
import { GhostButton, PG_PANEL, PG_PREVIEW, Slider } from "../swirl/controls";
import { SectionLabel } from "../section-label";
import { Arcade } from "./engine";
import { COLORWAYS, DEFAULTS, FONT_CSS, FONT_WEIGHT } from "./params";

export function ArcadePlayground() {
  const hostRef = useRef<HTMLDivElement>(null);
  const engineRef = useRef<Arcade | null>(null);

  const [word, setWord] = useState(DEFAULTS.word);
  const [threshold, setThreshold] = useState(DEFAULTS.threshold);
  const [cols, setCols] = useState(DEFAULTS.cols);
  const [keyline, setKeyline] = useState(DEFAULTS.keyline);
  const [shadow, setShadow] = useState(DEFAULTS.keylineOffset[0]);
  const [separation, setSeparation] = useState(DEFAULTS.separation);
  const [swim, setSwim] = useState(DEFAULTS.swim);
  const [pull, setPull] = useState(DEFAULTS.pull);
  const [magnetReach, setMagnetReach] = useState(DEFAULTS.magnetReach);
  const [texture, setTexture] = useState(DEFAULTS.texture);
  const [colorway, setColorway] = useState(0);

  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let engine: Arcade | null = null;
    let onScreen = false;
    let hidden = false;
    let started = false;

    const sync = () => {
      if (!engine || reduced) return;
      if (onScreen && !hidden) engine.start();
      else engine.stop();
    };

    const resolveFamily = () => {
      const probe = document.createElement("span");
      probe.style.cssText = "position:absolute;visibility:hidden";
      probe.style.fontFamily = FONT_CSS;
      probe.textContent = "Ag";
      document.body.appendChild(probe);
      const fam = getComputedStyle(probe)
        .fontFamily.split(",")[0]
        .replace(/["']/g, "")
        .trim();
      probe.remove();
      return fam;
    };

    const startEngine = (family?: string) => {
      if (started || !hostRef.current) return;
      started = true;
      engine = new Arcade(host, family ? `"${family}", sans-serif` : undefined);
      engineRef.current = engine;
      if (!engine.ok) return;
      // NO auto-cycling here. The gallery card runs itself because it has to be
      // alive in a grid; a playground that changes colour under you while you
      // are dragging a slider is fighting the person using it.
      if (reduced) engine.renderStill();
      else sync();
    };

    const hasFontApi =
      typeof document !== "undefined" && "fonts" in document && !!document.fonts;
    const raf = requestAnimationFrame(() => {
      if (!hostRef.current) return;
      const fam = hasFontApi ? resolveFamily() : "";
      if (hasFontApi && fam) {
        const to = window.setTimeout(() => startEngine(fam), 350);
        const go = () => {
          window.clearTimeout(to);
          startEngine(fam);
        };
        document.fonts.load(`${FONT_WEIGHT} 1em "${fam}"`).then(go, go);
      } else {
        startEngine();
      }
    });

    const io = new IntersectionObserver(
      (es) => {
        onScreen = es[0]?.isIntersecting ?? false;
        sync();
      },
      { threshold: 0.2 },
    );
    io.observe(host);

    const onVis = () => {
      hidden = document.hidden;
      sync();
    };
    document.addEventListener("visibilitychange", onVis);

    let rt = 0;
    const onResize = () => {
      window.clearTimeout(rt);
      rt = window.setTimeout(() => engine?.resize(), 120);
    };
    window.addEventListener("resize", onResize);

    return () => {
      cancelAnimationFrame(raf);
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      window.removeEventListener("resize", onResize);
      window.clearTimeout(rt);
      engine?.destroy();
      engineRef.current = null;
    };
  }, []);

  // Push params down as they change. The engine decides for itself which of
  // these need a new mask (word and ramp do; the rest are pure uniforms).
  useEffect(() => {
    engineRef.current?.setParams({
      word,
      threshold,
      cols,
      keyline,
      keylineOffset: [shadow, shadow],
      texture,
      separation,
      swim,
      pull,
      magnetReach,
    });
  }, [word, threshold, cols, keyline, shadow, texture, separation, swim, pull, magnetReach]);

  const remix = useCallback(() => {
    const next = (colorway + 1) % COLORWAYS.length;
    setColorway(next);
    engineRef.current?.setColorway(next);
  }, [colorway]);

  return (
    <div className="flex min-w-0 flex-col gap-4">
      <SectionLabel action={<GhostButton onClick={remix}>Remix</GhostButton>}>
        Playground
      </SectionLabel>

      <div className="flex min-w-0 flex-col">
        <div ref={hostRef} className={`${PG_PREVIEW} relative aspect-video`} />

        <div className={PG_PANEL}>
          <label className="flex h-8 w-full items-center gap-3 rounded-lg border border-[var(--border-line)] bg-[var(--bg-page)] px-3 text-[12px] text-[var(--text-secondary)]">
            <span className="shrink-0">Word</span>
            <input
              value={word}
              onChange={(e) => setWord(e.target.value.slice(0, 14))}
              spellCheck={false}
              className="min-w-0 flex-1 bg-transparent text-right text-[var(--text-primary)] outline-none"
            />
          </label>

          <div className="flex h-8 w-full items-center rounded-lg border border-[var(--border-line)] bg-[var(--bg-page)] px-3 text-[12px] text-[var(--text-secondary)]">
            <span>Colourway</span>
            <span className="ml-auto text-[var(--text-primary)]">
              {COLORWAYS[colorway].name}
            </span>
          </div>

          {/* The two that matter. */}
          <Slider
            label="Threshold"
            value={threshold}
            min={0.25}
            max={0.85}
            step={0.005}
            onChange={setThreshold}
          />
          {/* The source's own grid is 150 cells across. */}
          <Slider
            label="Grid"
            value={cols}
            min={40}
            max={260}
            step={2}
            format={(v) => `${Math.round(v)} cells`}
            onChange={setCols}
          />
          <Slider
            label="Keyline"
            value={keyline}
            min={0}
            max={8}
            step={1}
            format={(v) => `${Math.round(v)} cells`}
            onChange={setKeyline}
          />
          <Slider
            label="Shadow"
            value={shadow}
            min={0}
            max={6}
            step={1}
            format={(v) => `${Math.round(v)} cells`}
            onChange={setShadow}
          />
          <Slider
            label="Magnet"
            value={magnetReach}
            min={0}
            max={7}
            step={1}
            format={(v) => `${Math.round(v)} cells`}
            onChange={setMagnetReach}
          />
          <Slider
            label="Pull"
            value={pull}
            min={0}
            max={0.04}
            step={0.002}
            format={(v) => v.toFixed(3)}
            onChange={setPull}
          />
          <Slider
            label="Swim"
            value={swim}
            min={0}
            max={2.5}
            step={0.1}
            onChange={setSwim}
          />
          <Slider
            label="Separation"
            value={separation}
            min={0}
            max={3}
            step={0.1}
            onChange={setSeparation}
          />
          <Slider
            label="Texture"
            value={texture}
            min={0}
            max={1.6}
            step={0.02}
            onChange={setTexture}
          />
        </div>
      </div>
    </div>
  );
}

```

Discovery vocabulary

Related by governed terms

Continue comparing

More from Vault