Ransom note

STAYWEIRD

I always liked the ransom-note look, where every letter is torn from a different magazine and taped down crooked. So I took a set of real cut-out letters and made it into something you can type with.

Each character picks a random paper scrap from the set, then gets a small random tilt, bounce and size so the line reads like it was assembled by hand, not typed. Type your own note below, tune how messy it is, and re-roll to shuffle the scraps.

Playground

STAYWEIRD
Text
Chaos
Layout

Code

// 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 { mediaUrl } from "../../lib/video-sources";
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;

// where the sprites are served from (R2 in prod, /public in dev)
export const RANSOM_BASE = "/vault/ransom/";
export function spriteUrl(file: string): string {
  return mediaUrl(`${RANSOM_BASE}${file}`);
}

// 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
};

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)
};

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

// 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!;
    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 });
      continue;
    }
    const variants = RANSOM[key];
    const v = variants[Math.floor(rnd() * variants.length)];
    out.push({
      kind: "glyph",
      ch,
      variant: v,
      rot: (rnd() * 2 - 1) * jitter.rot,
      dy: (rnd() * 2 - 1) * jitter.dy,
      scale: 1 + (rnd() * 2 - 1) * jitter.scale,
      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.
      depth: 0.35 + rnd() * 0.65,
    });
  }
  return out;
}

Credits

CompanyStudy
DateJul 16, 2026
TagsType, Collage, Cutout
Sourceresourceboy.com

MIT → free to copy