Who is playing?0000

A name to run under. Start it with @ to use your x.com photo.

Leaderboard

404 runner

The game that keeps the missing pages company.

A 404 is the one page on a site nobody meant to open. It is also the one page with nothing to do on it, which is why this one has a runner on it instead of an apology.

It is a canvas and a loop, not a framework. The scenery is three parallax strips, each drawn once into an offscreen canvas and then scrolled, so a frame costs one blit per layer rather than a redraw of everything on it. The score is written straight into a text node by the animation loop, which means a whole run happens without React rendering once.

The name goes to the leaderboard when the run ends. Start it with an @ and it borrows your x.com photo, because a scoreboard of strangers is more fun with faces on it.

"use client";

/*
 * The scenery, and the numbers the run is tuned against.
 *
 * Split out of the game itself, which was eleven hundred lines in one file and
 * therefore not something anybody was going to read. Everything here is decided
 * before a run starts and does not change during one: the constants that make a
 * jump clear an obstacle, the glyph fields the parallax is made of, and the
 * baking that turns each field into a repeating pattern.
 *
 * The division is not cosmetic. Nothing in this file knows the game is running.
 * It builds scenery and hands it over, which is what makes it possible to look
 * at the loop next door without a screenful of tuning constants above it.
 */

export const HEIGHT = 200;
export const GROUND = 38;
export const RUNNER = 30;
export const GRAVITY = 2600; // px/s², tuned so one jump clears the tallest obstacle.
export const JUMP = 790;
export const START_SPEED = 300;
export const MAX_SPEED = 720;

export type Kind = "pine" | "boulder" | "spire";
/* `seed` is fixed at spawn, so a shape's variation holds still as it crosses. */
export type Obstacle = { x: number; height: number; kind: Kind; seed: number };
/* "ready" is a still board waiting on Play, not a board about to start. */
export type Phase = "ready" | "running" | "over";
export type Player = { label: string; isX: boolean };

export const SKY_GLYPHS = [".", "·", "*", "+", "'", "`", "˙", "•"];
export const RIDGE_GLYPHS = ["^", "^", "^", "'", "ʌ", "Λ"];
export const GRIT_GLYPHS = [".", "-", "·", "_", ",", "˙"];

/*
 * Each parallax layer is a strip drawn once into its own canvas and then
 * repeated as a fill pattern. Laid out per frame it was a couple of hundred
 * fillText calls sixty times a second for scenery that never changes; as a
 * pattern it is one fillRect a layer, and the scroll is a translate.
 *
 * Widths are a whole number of columns, so the strip meets itself cleanly, and
 * no two share a factor, so the three layers do not line up into one visible
 * repeat. They are long for the same reason: at the speeds these run, a short
 * strip comes round often enough to read as a loop rather than as scenery.
 *
 * A strip is only as tall as the band it draws in. The sky occupies the top
 * quarter of the board and the grit a strip of floor two characters deep •
 * storing either at the full height would be most of a megabyte of nothing,
 * three times over, on a page whose whole job is to apologise for itself.
 */
export const LAYERS = {
  sky: {
    glyphs: SKY_GLYPHS,
    columns: 44,
    spread: 38,
    alpha: 0.2,
    speed: 0.2,
    // Absolute, and independent of the floor: this band hangs from the top.
    // Flush with it, because the tallest glyph at the highest baseline reaches
    // the very top and a band that started lower would shave its cap off.
    band: () => ({ top: 0, height: 100 }),
    field: { bias: 1.7, sizes: [9, 10, 12, 12, 14] },
  },
  /* Scrub along the foot of the hills, between the ranges and the floor. */
  ridge: {
    glyphs: RIDGE_GLYPHS,
    columns: 29,
    spread: 40,
    alpha: 0.38,
    speed: 0.5,
    band: (floor: number) => ({ top: floor - 26, height: 28 }),
    field: { bias: 0.8, sizes: [10, 12, 12, 13] },
  },
  grit: {
    glyphs: GRIT_GLYPHS,
    columns: 37,
    spread: 34,
    alpha: 0.32,
    speed: 1,
    band: (floor: number) => ({ top: floor - 4, height: 26 }),
    field: { bias: 1, sizes: [9, 10, 12] },
  },
} as const;

/*
 * The two silhouetted ranges behind everything.
 *
 * Far is large, faint and nearly still; near is smaller, firmer and moves
 * three times as fast. That pairing is the whole trick of depth here • a
 * single range at a single speed reads as wallpaper however it is drawn, and
 * two of them read as distance without either needing a gradient or a second
 * colour.
 *
 * Their spans (1632 and 1196) share no useful factor, so the pair does not
 * come back round together into one recognisable skyline.
 */
export const RANGES = {
  far: { count: 24, spread: 68, low: 34, high: 64, alpha: 0.075, speed: 0.05 },
  near: { count: 26, spread: 46, low: 18, high: 40, alpha: 0.13, speed: 0.15 },
} as const;

/*
 * The shard is drawn at a fixed width whatever its height, so its hitbox is a
 * fixed width too. Scaling it with height the way the other two do would put
 * the edge of the box six pixels clear of a tall one • which is a death with
 * nothing on screen to explain it.
 */
export const SPIRE_HALF = 10.5;

export type LayerName = keyof typeof LAYERS;

/*
 * A range, as one continuous ridgeline.
 *
 * It used to be a row of separate triangles sharing a baseline, which is what
 * it looked like: overlapping cones rather than land. A single closed polygon
 * running peak to valley to peak reads as one mass with a far side, and costs
 * the same forty line segments to draw.
 *
 * Points alternate: even indices are summits, odd ones the saddles between
 * them. `count` must be even, and the last point repeats the first, so the
 * range meets itself when it wraps and there is no step at the seam.
 */
export function makeRidgeline(
  count: number,
  spread: number,
  low: number,
  high: number,
) {
  const points = Array.from({ length: count }, (_, i) => ({
    x: i * spread,
    y:
      i % 2 === 0
        ? low + Math.random() * (high - low)
        : low * (0.2 + Math.random() * 0.35),
  }));
  points.push({ x: count * spread, y: points[0].y });
  return { points, span: count * spread };
}

export type Ridgeline = ReturnType<typeof makeRidgeline>;

export function readInk() {
  const styles = getComputedStyle(document.documentElement);
  const get = (name: string) => styles.getPropertyValue(name).trim();
  const ink = get("--text-primary") || "#101010";

  /*
   * globals.css raises its edge alphas in dark mode because a dark hairline
   * disappears on near-black. The background here needs the opposite
   * correction: white on #191919 carries much further than black on #fcfcfc,
   * so at one set of alphas the light board came out anaemic and the dark one
   * came out loud. One gain, applied to every background value, is what keeps
   * the two boards the same picture.
   *
   * lib/theme.ts toggles this class for every resolved theme, the system one
   * included, so it is the same answer the CSS is working from.
   */
  const dark = document.documentElement.classList.contains("dark");

  return {
    ink,
    quiet: get("--text-tertiary") || "#9a9a9a",
    surface: get("--surface") || "#ffffff",
    gain: dark ? 0.78 : 1,
  };
}

/*
 * A fixed field, generated once. Rolling new characters every frame would make
 * the background shimmer rather than scroll, and the point of a parallax layer
 * is that it is the same scenery seen from further away.
 *
 * Every speck also gets a weight and a size. A field at one alpha and one size
 * is a screen door: the eye reads the grid rather than the depth. Varying both
 * is free • it is baked into the strip once • and it is the difference between
 * a texture and a sky.
 */
export function makeField(
  count: number,
  glyphs: readonly string[],
  spread: number,
  options: { bias?: number; sizes?: readonly number[] } = {},
) {
  const { bias = 1, sizes = [12] } = options;
  const pick = <T>(from: readonly T[]) =>
    from[Math.floor(Math.random() * from.length)];

  return Array.from({ length: count }, (_, i) => ({
    x: i * spread + Math.random() * spread,
    // Above 1 this crowds the near edge of the band, which is what gives the
    // sky a dense roof thinning out towards the horizon rather than an even
    // scatter from top to bottom.
    y: Math.random() ** bias,
    glyph: pick(glyphs),
    size: pick(sizes),
    weight: 0.4 + Math.random() * 0.6,
  }));
}

export type Field = ReturnType<typeof makeField>;

/*
 * One layer, baked. The colour is painted in, so the strips are rebuilt when
 * the theme flips • which happens about as often as somebody clicks the theme
 * toggle, rather than sixty times a second.
 *
 * Glyphs near either end are drawn a second time a strip-width away, so a
 * character straddling the seam is whole on both sides of it.
 */
export function bakeLayer(
  name: LayerName,
  field: Field,
  colour: string,
  floor: number,
  dpr: number,
) {
  const { spread, columns } = LAYERS[name];
  const stripWidth = columns * spread;
  const band = LAYERS[name].band(floor);

  const strip = document.createElement("canvas");
  strip.width = Math.round(stripWidth * dpr);
  strip.height = Math.round(band.height * dpr);
  const paint = strip.getContext("2d");
  if (!paint) return null;

  paint.setTransform(dpr, 0, 0, dpr, 0, 0);
  paint.fillStyle = colour;
  paint.textAlign = "center";
  paint.textBaseline = "alphabetic";

  for (const item of field) {
    // Board coordinates, then shifted into the strip's own space.
    const y =
      (name === "sky"
        ? 18 + item.y * 70
        : name === "ridge"
          ? floor - 5 - item.y * 12
          : floor + 9 + item.y * 8) - band.top;
    // Baked in, so the variation costs nothing at draw time.
    paint.globalAlpha = item.weight;
    paint.font = `${item.size}px ui-monospace, SFMono-Regular, Menlo, monospace`;
    paint.fillText(item.glyph, item.x, y);
    // The wrap: whatever hangs off one end is redrawn onto the other.
    if (item.x < 12) paint.fillText(item.glyph, item.x + stripWidth, y);
    if (item.x > stripWidth - 12)
      paint.fillText(item.glyph, item.x - stripWidth, y);
  }
  paint.globalAlpha = 1;

  const pattern = paint.createPattern(strip, "repeat");
  // The strip is stored at device resolution; the pattern is used in CSS
  // pixels, so it is scaled back down or every glyph doubles in size.
  pattern?.setTransform(new DOMMatrix().scale(1 / dpr, 1 / dpr));
  return pattern ? { pattern, stripWidth, band } : null;
}