ANISHFN
Text
Chaos
Layout

Tap a letter to swap it, or drag one out of line.

Ransom note

Anything you type, in cut-out magazine letters.

A ransom note is words cut out of magazines and glued down one letter at a time. Nothing matches, and that is the point: no handwriting to recognise, no typewriter to trace.

People fake the look with a font, which is the one thing it cannot be. What you are looking at is not a typeface, it is evidence of a hand. So every letter here is a photograph of a real paper cutting, pulled at random from a pile.

// Ransom-note cutout manifest + per-character picker. The sprites are real torn/cut-out
// magazine letters (Resource Boy "Ransom Note Letters" pack, royalty-free), trimmed to
// their alpha bbox and downscaled to ~220px tall WebP. Each key (a character) maps to a
// handful of variants; composing a word means picking one cutout per character and
// jittering it a little so it reads like a hand-assembled ransom note.

import manifestJson from "../../../public/vault/ransom/manifest.json";

export type Variant = { file: string; w: number; h: number };
export type Manifest = Record<string, Variant[]>;

export const RANSOM: Manifest = manifestJson as Manifest;

// Ported from a setup that served these off R2 behind a `mediaUrl` helper. This site
// has no media CDN, so the sprites are served straight out of /public.
export const RANSOM_BASE = "/vault/ransom/";
export function spriteUrl(file: string): string {
  return `${RANSOM_BASE}${file}`;
}

// True once the pack is actually installed. Everything downstream renders a gap for a
// character it has no cutout for, so an empty manifest is a blank note rather than a
// page of broken images • but the embed checks this so it can say so out loud.
export function hasPack(): boolean {
  return Object.keys(RANSOM).length > 0;
}

// Characters we have cutouts for. Everything else (space, unknown) is rendered as a gap.
export function hasGlyph(ch: string): boolean {
  return RANSOM[ch] !== undefined;
}

// A small deterministic PRNG (mulberry32) so a given seed reproduces the same note — the
// hero holds a stable composition per phrase, and "Re-roll" just bumps the seed.
export function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// hash a string to a stable 32-bit seed (so the same phrase defaults to the same note)
export function hashSeed(s: string): number {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}

export type Placed = {
  kind: "glyph" | "space";
  ch: string;
  variant?: Variant;
  rot: number; // deg tilt
  dy: number; // baseline offset, in em of the line height
  scale: number; // per-letter scale multiplier
  mx: number; // extra horizontal gap after (em)
  depth: number; // 0..1 parallax depth (seeded) — far scraps lean less toward the cursor
  lift: number; // 0..1 resting height off the page (drives the drop shadow)
};

export type JitterConfig = {
  rot: number; // max |tilt| in deg
  dy: number; // max baseline bounce (em)
  scale: number; // scale variance (0.1 => 0.9..1.1)
  gap: number; // max extra gap between letters (em)
  lift: number; // max resting lift off the page (0 = every scrap glued flat)
};

export const DEFAULT_JITTER: JitterConfig = {
  rot: 8, // lively but readable
  dy: 0.06,
  scale: 0.12,
  gap: 0.05,
  lift: 0.35,
};

/*
 * What the playground starts at. Wider spacing than DEFAULT_JITTER, which is the sensible
 * default for a line composed by something else.
 */
export const PLAYGROUND_JITTER: JitterConfig = {
  rot: 8,
  dy: 0.06,
  scale: 0.12,
  gap: 0.2,
  lift: 0.35,
};

// All cutout variants available for a given character (via its manifest key), or [] if none.
// Used by click-to-swap so a scrap can flip to a *different* scrap of the same letter.
export function variantsFor(ch: string): Variant[] {
  const key = keyFor(ch);
  return key ? RANSOM[key] : [];
}

// Resolve a character to a manifest key. Uppercase/lowercase share a folder in the pack
// (variants already mix cases), so we upcase letters; a few symbols map to shared keys.
function keyFor(ch: string): string | null {
  if (ch === " ") return null;
  const up = ch.toUpperCase();
  if (RANSOM[up]) return up;
  if (RANSOM[ch]) return ch;
  if (ch === "(" || ch === ")") return RANSOM["()"] ? "()" : null;
  if (ch === "." && RANSOM[","]) return null; // no period cutout; render as a small gap
  return null;
}

// Natural rendered width of a composed line at a given letter height (px). Mirrors the
// layout in RansomLine (per-letter width = aspect*scale*lineH, spaces = 0.32em, a small
// inter-item gap of 0.04em, plus each glyph's extra mx gap). Used to fit a line to one row.
export function lineWidth(placed: Placed[], lineH: number): number {
  let w = 0;
  const gap = lineH * 0.04;
  placed.forEach((p, i) => {
    if (i > 0) w += gap;
    if (p.kind === "space") {
      w += lineH * 0.32;
      return;
    }
    const v = p.variant;
    if (!v) return;
    w += (v.w / v.h) * (lineH * p.scale) + p.mx * lineH;
  });
  return w;
}

// Compose a line of text into placed cutouts using a seed. Deterministic for (text, seed).
export function composeLine(
  text: string,
  seed: number,
  jitter: JitterConfig = DEFAULT_JITTER,
): Placed[] {
  const rnd = mulberry32(seed);
  const out: Placed[] = [];
  for (const ch of text) {
    const key = keyFor(ch);
    if (!key) {
      out.push({
        kind: "space",
        ch,
        rot: 0,
        dy: 0,
        scale: 1,
        mx: 0,
        depth: 0,
        lift: 0,
      });
      continue;
    }
    const variants = RANSOM[key];
    const v = variants[Math.floor(rnd() * variants.length)];
    // Drawn in order, because the sequence is the composition: pulling a number earlier
    // than it used to be pulled re-rolls every note on the site.
    const rot = (rnd() * 2 - 1) * jitter.rot;
    const dy = (rnd() * 2 - 1) * jitter.dy;
    const scale = 1 + (rnd() * 2 - 1) * jitter.scale;
    const mx = rnd() * jitter.gap;
    // seeded parallax depth: 0.35..1. Near scraps (→1) lean most toward the cursor,
    // far ones barely move, so the taped collage gets real depth on hover.
    const depth = 0.35 + rnd() * 0.65;
    out.push({
      kind: "glyph",
      ch,
      variant: v,
      rot,
      dy,
      scale,
      mx,
      depth,
      /*
       * Resting lift reuses depth rather than drawing its own number: a scrap that reads
       * as nearer should also be the one casting the longer shadow, and the two falling
       * out of step is exactly what makes a collage look composited rather than glued.
       */
      lift: jitter.lift * depth,
    });
  }
  return out;
}

Get in touch

anishhfn@gmail.com