Skip to content

Building Framemoji: a tiny daily emoji movie game

November 7, 2025 (10mo ago)

211 views

I wanted a small project I could iterate on quickly, something shippable in a day that still left room for polish. Framemoji is what came out: a daily movie guessing game where you decode a film from up to ten emojis. Play it at https://framemoji.ainsworth.dev.

It started simple (“one page, one API”), and then the quality-of-life requests pushed it into a surprisingly complete product: better matching, a histogram with popular guesses, UTC midnight rotation, a keyboard-only flow, and stats that hold up in production.

What I built

  • Daily game mode: everyone plays the same movie per day; it flips at 00:00 UTC.
  • Ten emoji clues: one per wrong guess; solve earlier, score higher (10 → 1).
  • Percentile stat: “You’re better than X% of players today.”
  • Histogram: bars for reveals 1 to 10, plus a ❌ fail bucket; click a bar to see popular guesses at that step.
  • Keyboard-only play: type, arrow-key select, Enter to submit; top suggestion auto-selects.
  • Autocomplete: fuzzy, accent-insensitive, popularity-sorted suggestions (TMDB data).
  • Answer poster: after you finish, the results panel can show the movie poster (via TMDB) if NEXT_PUBLIC_TMDB_IMAGE_BASE is configured.
  • Dev tools: when no secret is set, the UI shows the answer, a “Pin today’s puzzle” form for local testing, and a “clear local data” button.

Why it was fun

The mechanic is familiar, but emojis force you into visual storytelling. Ordering the clues from vague to obvious is what makes the “aha” land, and you can feel the tension climb as the grid fills up.

Technical bits that mattered

  1. App Router + lean client components

    • Next.js (App Router, TS strict), a tiny client component for gameplay, and server routes for the daily selector and stats. Very little JavaScript ships to the browser.
  2. Deterministic daily selection (no spoilers)

    • index = HMAC(secret, YYYY-MM-DD) % N, where N is the dataset length. Secret lives in FRAMEMOJI_DAILY_SECRET (falls back to EMOVI_DAILY_SECRET for compatibility) so you can’t precompute answers. Rotation is strictly UTC.
    • To guarantee consistency across routes and cold starts, the chosen puzzle ID is “pinned” for the current day (stored in KV if configured, otherwise a local file). In dev/file mode, there’s an endpoint to pin a specific ID for testing.
  3. Title matching that feels human

    • Normalized on case, diacritics, and punctuation, stripped articles, and converted Roman numerals to Arabic. One gotcha took a while: keep a lone “I” as “i”, so “monsters i” still matches “Monsters, Inc.”
  4. Emojis that don’t jiggle

    • Grapheme segmentation for reveal steps (no half-emoji), plus an .emoji-inline wrapper that normalizes baseline/height so flags and multi-codepoint sequences don’t shift the row.
    • Dynamic grid: start at 5 columns, then grow 6/7/…/10 as clues reveal so emojis are always as large as possible.
  5. Percentiles you can trust

    • The “better than X%” stat counts strictly worse outcomes. Fails are worse than any solve; if you fail you’re at 0%.
    • A UTC countdown shows time to the next game; we compute in UTC so the label is stable regardless of local timezone.

UX polish that paid off

  • Keyboard-first flow: input autofocus, arrow navigation, Enter submits the highlighted suggestion, query resets selection to the top.
  • Emoji histogram labels: the x-axis uses the actual reveal emojis (plus ❌) at a larger size.
  • Guess insights: click a bar to see the most popular guesses at that moment, rendered as horizontal bars (not raw numbers). Failures show just the fail count.
  • Copy that matches reality: “You’re better than X% of players today.”
  • Accessible by design: combobox semantics and listbox roles for suggestions, live regions for wrong-guess feedback and clue changes, focus management on finish, and a “Skip to content” link.

Infrastructure and data

  1. Hybrid stats backend

    • Local dev: JSON files in var/stats/YYYY-MM-DD.json, which are quick to write and easy to diff.
    • Production: Vercel KV (Upstash Redis) with atomic increments:
      • framemoji:YYYY-MM-DD:solves → hash fields r1..r10, fail (HINCRBY)
      • framemoji:YYYY-MM-DD:guesses2:rN → hash of normalized guesses with counts (HINCRBY); top N sorted in the app
    • Env flag EMOVI_USE_FILE_STATS=1 forces file mode even when KV is present (useful for local debugging on prod-like builds).
    • The API transparently uses KV when configured and falls back to file mode when not, so it’s easy to test locally.
  2. Autocomplete you can ship

    • Two scripts to maintain public/data/movies.json:
      • Build from TMDB export (filters: year ≥ 1950, popularity, Latin titles, cap to ~50k)
      • Incremental updates via /movie/changes + per-movie details
    • Optional enrich step fills missing year values.
    • We sort suggestions by “votes desc → popularity desc → title”, and match words at boundaries first (fallback to mid-word only if nothing hits).
    • Poster images: the UI resolves a poster after you finish by matching the final title against the TMDB list (prefers exact match with a poster, then word-boundary partials; ranks by year proximity and vote_count/popularity).
  3. Local streaks (client-side)

    • UTC-based streaks and best score tracked in localStorage under framemoji:dailyStats, with automatic migration from the old emovi:dailyStats key.
  4. Dataset for clues

    • A JSON schema with id, title, year, emoji_clues[10]. We started with a curated ~100 and can scale to 1000+ over time.

A few bugs I enjoyed fixing

  • Emoji reveal slicing initially split ZWJ sequences in half; Intl.Segmenter fixed it.
  • Hydration mismatch on streaks (server 0/0 vs client localStorage): render placeholders until mount.
  • “mon” matching “demon”: tightened matching to word starts, with fallback only when zero results.
  • Histogram 10 vs ❌ confusion: fail bucket now uses a separate sentinel (0) so the tenth emoji bar isn’t conflated with failures.
  • Baseline jitters: normalized inline emoji sizing for title lines and the grid.

What I’d improve next

  • Serve a daily share card (OG image) showing your reveal step and an emoji strip.
  • Soft rate limiting (per IP) on guess/finish endpoints via KV tokens.
  • A tiny “today’s winner” section with a few of the most frequent wrong guesses, if it can be done without being mean about it.
  • Expand the dataset and add a human review pass for clue quality.

Takeaways

  • Crisp mechanics beat big scope. One puzzle a day, one movie, ten clues, and it already feels finished.
  • The last 20% is where the game actually got good: the keyboard flow, the emoji sizing, the baseline alignment, and the wording of the results copy.
  • The hybrid KV and file approach let me keep fast local loops without giving up safe concurrency in production.

Framemoji picks a new movie every day at 00:00 UTC, and gives you ten emojis to work it out. Play it at https://framemoji.ainsworth.dev.

Written by Sam Ainsworth.

my face
© Sam Ainsworth 2024 - 2026. All Rights Reserved.privacy