The Papermoon Theatre · colophon

The prompt book

How this site was cut, folded and glued. One stage, eight paper flats, one fox, one moon on a string. Everything you saw was drawn by code at load time; nothing was photographed, generated or downloaded.

The brief, as received

Concept
A children’s shadow-puppet theatre company. Everything is cut paper. Scroll advances four acts of “The Fox Who Stole the Moon” (~40 words each), layers slide like stage flats.
Palette
Midnight #1d2a44 · warm paper #f3e9d2 · coral #e2725b · moon gold #e8c56a. Tints and shades derived from these four; nothing else.
Type
Baloo 2 (display) + Nunito (body), vendored as variable-weight woff2 from fontsource. Both files shipped complete with real italics for Nunito; nothing was missing, so nothing was substituted.
Signature techniques
5–7 depth-layer SVG cutout scenes with torn, deckled edges (a written edge-roughen helper); mouse/gyro parallax with per-layer depth; feTurbulence paper grain on every fill; a torch that follows the cursor, because shadow theatre.
Must prove
That SVG illustration plus narrative scroll can enchant without a single raster asset — and the fox must be genuinely charming.

Nothing is machine-straight

Every visible edge passes through deckle(): it subdivides each designed edge into short pieces, nudges every new vertex along the edge normal, tears the occasional deeper nick, then relaxes the jitter once so it reads as paper fibre rather than sawtooth. Corners marked sharp stay put — that is what keeps the fox’s ears pointed and the stars spiky.

js/paper.js — the edge-roughener

export function deckle(pts, { amp = 2, step = 8, seed = 1, close = true, relax = 1 } = {}) {
  const R = rng(seed);                      // mulberry32: every tear is repeatable
  const out = [];
  const n = pts.length;
  const lim = close ? n : n - 1;
  for (let i = 0; i < lim; i++) {
    const [x0, y0, s0] = pts[i];
    const [x1, y1] = pts[(i + 1) % n];
    out.push({ x: x0, y: y0, fixed: true, sharp: !!s0 });
    const dx = x1 - x0, dy = y1 - y0;
    const len = Math.hypot(dx, dy) || 1;
    const m = Math.max(1, Math.round(len / step));
    const nx = -dy / len, ny = dx / len;    // edge normal
    for (let j = 1; j < m; j++) {
      const t = j / m;
      let k = R() * 2 - 1;
      if (R() < 0.085) k *= 2.4;            // the occasional nick
      out.push({ x: x0 + dx * t + nx * k * amp, y: y0 + dy * t + ny * k * amp, fixed: false });
    }
  }
  for (let r = 0; r < relax; r++)           // one smoothing pass: fibre, not noise
    for (let i = 0; i < out.length; i++) {
      const p = out[i];
      if (p.fixed) continue;
      const a = out[(i - 1 + out.length) % out.length];
      const b = out[(i + 1) % out.length];
      p.x = (a.x + 2 * p.x + b.x) / 4;
      p.y = (a.y + 2 * p.y + b.y) / 4;
    }

The paper grain itself is one shared feTurbulence filter, composited into each shape’s alpha and blended soft-light, with two heavier variants that add drop shadows for lifted flats. The tickets and cards below the stage get hand-cut edges a cheaper way: a displacement filter (feTurbulencefeDisplacementMap, scale 7) applied to a plain backing div.

One smoothed number runs the whole show

The stage is a sticky viewport inside a 560vh section. Scroll maps to a progress value p ∈ [0,1], lerped each frame; the story is a list of beats — partial states that carry forward, interpolated with smoothstep. The same file drives the fox’s pose (head, foreleg, tail, eyelid — she is four paper pieces on gold brads), the moon’s pendulum, the darkness, the river’s silver. Positions are fractions of a width-adaptive viewBox (height fixed at 1000 units), so the set is re-cut for narrow stages rather than cropped.

js/scene.js — beats, abridged

const B = (p, o = {}) => { cur = { ...cur, ...o }; beats.push({ p, s: cur }); };
B(0.000);                                            // dusk; she sits, wanting
B(0.175, { head: -17, tail: 7 });                    // negative head = nose up
B(0.305, { fx: 0.56, walk: 1, treeX: 0.26 });        // the spruce flat is carried in
B(0.360, { fx: 0.77, fy: 745, fr: -62, arm: 30 });   // up the trunk
B(0.448, { fx: foxBr, fy: 474, flip: 1, bend: 0.9,   // out along the branch,
           tail: -46, mx: hookX, my: 438 });         //   which bends like a fishing rod
B(0.475, { att: 0, ms: 0.6 });                       // off the hook
B(0.578, { fx: denStop, den: 1, dim: 0.66, owl: 1,   // blind-dark; the den glows
           hole: 1, river: 0.12 });
B(0.615, { dark: 1 });                               // the dark, quite kindly
B(0.825, { mx: AX - 0.12, my: 330, att: 0.7 });      // hoisted home, left of the spruce
B(0.875, { swing: 5.2 });                            // the pat. it swings still.

js/scene.js — parallax: flats answer the hand

const DEPTH = { 'l-sky': 5, 'l-clouds': 10, 'l-hillsfar': 20, 'l-moon': 14,
  'l-forest': 32, 'l-tree': 44, 'l-fox': 56, 'l-flies': 64, 'l-fore': 78, 'l-rig': -8 };

nx += (nxT - nx) * 0.1; ny += (nyT - ny) * 0.1;      // lerped, never raw
for (const key in flats) {
  const w = flats[key].wrap, d = DEPTH[w.className.split(' ')[1]] || 0;
  w.style.transform =
    `translate3d(${(nx * d).toFixed(1)}px, ${(ny * d * 0.55).toFixed(1)}px, 0)`;
}

The torch is two layers driven by the same lerped cursor: a warm additive glow, and a mask hole cut in the darkness overlay — in Act III the scene dims to 0.66 and the torch becomes the only way to see the meadow, which is the whole point of shadow theatre.

css/theatre.css — the torch

.dim {
  background: #070d1d; opacity: 0;                /* the beat engine sets this */
  mask-image: radial-gradient(circle 300px at var(--tx, 50%) var(--ty, 45%),
      rgba(0,0,0,.14) 0, rgba(0,0,0,.62) 46%, #000 100%);
}
.torch {
  background: radial-gradient(circle 250px at var(--tx, 50%) var(--ty, 45%),
      rgba(232,197,106,.17), rgba(232,197,106,.055) 46%, transparent 72%);
  mix-blend-mode: screen;
}

Provenance

  • Every pixel is procedural: SVG paths, gradients and filters built at runtime by js/paper.js, js/fox.js and js/scene.js. No photography, no stock, no AI-generated imagery, no raster assets of any kind.
  • Fonts: Baloo 2 and Nunito via the Google Fonts fontsource packages, vendored locally as latin variable-weight woff2 (three files, ~90 KB total). No CDN requests at runtime.
  • No frameworks, no build step, no external requests. The theatre, its people and its address are fictional; Whitby is real and deserves a theatre like this.
  • Reduced motion honoured: twinkle, dangling stars, dizzy owl stars, fireflies, shooting star, moon swing, idle sway and parallax all stop; the scroll story still resolves each act instantly, so nothing is lost but the wobble. Fern still bows if you applaud — her confetti stays in the tin.

The critique log, honestly

  • Pass 1 — First render was competent and wrong in nine places. The valance was a fat coral slab that read as a website header; Act I’s copy sat directly on the moon; the head-tilt sign was inverted, so Fern spent the wanting scene studying the grass; the climbing branch was buried inside the spruce foliage and invisible; the river ran under her seat; the owl gag read as a fallen snowman; the kindly dark’s eyes crossed the Act III text; the returning moon impaled itself on the branch; and the “pale patch where the moon used to hang” lingered like a ghost. All fixed: slimmer valance, act copy re-slotted, signs flipped, branch drawn over the foliage in a lighter shade, river rerouted, owl rebuilt smaller with legs in the air, eyes moved below the copy, a high-left transit arc for the hoist. Upgrade: fireflies over the meadow, brighter the darker the act.
  • Pass 2 — The est. line was gold on coral and unreadable, so it moved below the valance; the hook moment became a fishing composition (moon lowered beneath the bent branch — she now embraces it, which is better than the plan); guilt tilt eased so her face stopped burying itself in her bib; a hedge sat exactly on her ears in Act IV; the sky-hole and empty hook were invisible under the dim; a double-string artifact showed during the hoist; the footer was lopsided; table dates wrapped; troupe pegs were 4px off their strings. All fixed. Upgrade: applause — click the stage at “fin.” and Fern takes a bow under a burst of paper stars.
  • Pass 3 — Moving the est. line had pushed the nav left and hung the left rig star straight through “EST. 2011”; nav re-justified, star lowered on its string. Full walk of every act, both viewports: the remaining flaws are taste, not defects — the moon reads slightly olive under its grain (it is a butter biscuit; allowed), and Act II leaves two consecutive copy blocks on the left because the right side is busy with tree, moon and string (stage traffic beats symmetry). Console and network clean throughout. Upgrade: a paper shooting star crosses the rig every half-minute — never during the blind-dark, that would undermine the plot.