← Projects

A still appears, you narrow the year in stages, and every correct narrowing banks a point while you play at wyit.film.

Status
live at wyit.film
Monthly cost
$0
Infra
static CDN, 2 GETs per round
Payload
~198 KB gzip cold first round
Performance
Lighthouse 97 perf
Corpus
4,028 films, 1915–2025

Address space

FilmGrab stills filtered for year + intact image are assigned offline to slots 000000004027 by a deterministic seeded shuffle, with each slot backing two CDN files:

datasets/items/v1/{slotId}.json     — ~230 bytes: title, year, edges
images/items/v1/{slotId}.webp       — ≤1024px wide, q76

The WebP caps are a cell-network budget: 1–2s to first painted image. ≤1024px wide at q76 is what fit.

The six-digit ID is the lookup protocol. Both URLs derive from it, so the image fetch starts the moment a slot exists, in parallel with metadata rather than after a separate lookup. The client ships with slotCount = 4028 and a pad-to-six function. Per-year indexes and manifests exist for tooling. → Offline validation (Celebrity Golf, same static-corpus move).

Picking a slot

const buf = new Uint32Array(1)
crypto.getRandomValues(buf)
return buf[0] % total

Modulo bias exists (2³² ≢ 0 mod 4028); relative bias ~9×10⁻⁷ is negligible. Distribution is uniform over films even though the calendar is not: the 2010s hold 1,107 slots (27%); eight years have ≤2 films. Random mode excludes repeats in-session; daily persists under daily:{dateKey}.

Daily mode swaps the pointer: daysSince(epoch, localMidnight) % slotCount into a second versioned shuffle (seed "wyit-daily-v1"). Every player lands on the same slot with no shared server state. I walked a permutation instead of hash(date) % n so the cycle visits every slot before any repeat (~11 years of dailies). Day index is the puzzle number for free. Local midnight is the day boundary because that’s what Wordle established; with no backend, a global clock would buy nothing anyway.

Accepted tradeoffs: device timezone can time-travel the puzzle, and scores are client-trust, so refresh-to-retry works with no shared server clock.

Address space

Diagram of offline corpus assignment into a flat slot range, runtime random dereference into parallel JSON and WebP fetches, and daily mode via a second shuffle with local-midnight boundaries and localStorage state
Build time assigns the corpus into a flat slot range with two CDN files per slot. Runtime dereferences a random number into parallel JSON and WebP fetches; daily mode uses a second shuffle keyed by local-midnight day index.

One localStorage schema keyed daily:{dateKey} covers mid-game resume, return-visit summary, and share (full guess path stored as a Wordle-style emoji grid).

Two requests and ~200 KB

  1. App shell: HTML 1.5 KB + CSS 8.7 KB + JS 165.7 KB gzipped
  2. GET {slotId}.json (~230 B, max-age=300)
  3. GET {slotId}.webp (22.6 KB avg, 6.9–32.8, max-age=31536000, immutable)

An inline script in index.html draws the slot and preloads JSON + WebP before React hydrates. The client then keeps 5+2 rounds ahead, eagerly preloads the first three stills, and prefetches each slot’s four graph edges (baked offline). We can prefetch in the time the user takes to finish a round; those depths are what fits inside that window.

System fonts only (I didn’t want to ship a font download). JS is 84% of cold payload: one 505 KB raw bundle. At that size, splitting never came up.

The narrowing engine

Originally it was hard buckets: pick a century, then a decade, then a year. Being off by one was too unforgiving. Even now most people don’t score very high on any image. Runtime derives the year options shown on the drag pill from the current parent range under these four constraints:

  • ≤4 options per range step
  • no option narrower than 2 years mid-game
  • adjacent options overlap ≥2 years
  • final step: exact years, ≤6
initial: [1900, 2025],
steps: [
  { bins: 5, widthBins: 2, strideBins: 1, windows: 4 },
  { bins: 9, widthBins: 3, strideBins: 2, windows: 4 },
  { bins: 9, widthBins: 3, strideBins: 2, windows: 4 },
],
exactWidth: 5, maxSteps: 5,

Step one on the full range: 1900–1949 · 1925–1975 · 1950–2000 · 1976–2025. Each pick becomes the next parent; past the explicit steps an adaptive rule subdivides until the range fits in 5 years / ≤6 candidates. Scoring: each correct narrowing banks one point; a wrong commitment ends the round.

I wanted a physical sense of things snapping into one of the 3–5 options, while implying a sliding overlapping range. There’s no reason it has to be a slider at all: the input is discrete, and buttons would work. The drag pill is there to make the overlapping-window structure felt.