Each round names a film and shows three lines of dialogue. One was actually spoken in that film. The other two are real quotes from other movies, chosen because they sound like they could belong. You pick, the answer is revealed along with which films the impostors came from, and the next round loads. A game is ten rounds.
The design premise is that a multiple-choice quiz is only as good as its wrong answers. A decoy that is obviously from a different genre or era gives itself away before you read it. So the decoys are selected by semantic similarity: how close a line reads to the real one, measured with sentence embeddings, blended with how similar its source film feels. That similarity score is also what sets the difficulty and the point value of each round.
Building the corpus
Films come from the TMDb discover API: English-language releases with at least 1,000 votes and a rating of 6.5 or higher, collected against per-decade quotas from the 1950s through the 2020s. The quotas total 5,000 films and reserve roughly 17% for pre-1980 releases, so the corpus is not just recent blockbusters. Each film also gets its TMDb genre and thematic keyword IDs, which matter later for picking decoys. The fetcher caches every response on disk, bounds concurrency, and backs off honoring Retry-After, so re-runs cost nothing and the API is never hammered.
Dialogue, not screenplays
The quotes themselves come from Springfield! Springfield!, which hosts dialogue-only transcripts. Titles are matched by normalized name, with the release year required to agree within one year to avoid pairing a remake with its original. The repo also contains a full IMSDb screenplay source and a cue-based screenplay parser (a character cue is a short all-caps line that is not a scene heading or transition; the dialogue is everything until the next blank line or cue), but the fetch stage deliberately does not use it. Screenplays leak scene description into the quote pool, and "He walks slowly to the door" is not something anyone said on screen. Transcripts sidestep the problem by containing nothing but speech.
What counts as a quotable line
Raw transcripts are noisy. Each one is split into sentences and scrubbed: bracketed sound cues and parentheticals are stripped, including orphaned halves left behind by subtitle line breaks, leading speaker labels are dropped, and common OCR damage is repaired ("l'm" becomes "I'm", "wiII" becomes "will", it"s becomes it's).
A cleaned sentence then has to pass a battery of checks to become a candidate quote: 30 to 180 characters, at least four words, at least 60% letters, more lowercase than uppercase (which rejects credits and shouted stage text), a healthy unique-word ratio (which rejects song lyrics and chants), and a capital first letter. Most importantly, it has to read like speech: first or second person pronouns, a verb contraction, or a question or exclamation mark. Third-person narration has none of these signals. Each film contributes at most 100 quotes, sampled evenly across the transcript so a long film's quotes span its whole runtime.
Decoys by nearest neighbor
Every quote is embedded with bge-small-en-v1.5 running locally through Transformers.js, mean-pooled and unit-normalized, so cosine similarity reduces to a dot product. The vectors are written as one flat Float32Array binary; JSON at this scale would be hundreds of megabytes.
Exhaustive nearest-neighbor search over every quote pair is quadratic, so the search is restricted: each quote is only compared against quotes from its film's 60 most similar films. Film similarity is its own blend, \(0.5\) times the Jaccard index over genres, \(0.2\) times decade proximity (decaying to zero at 40 years apart), and \(0.3\) times Jaccard over thematic keywords. The restriction is a speedup, but it is also a quality win: decoys come from adjacent-world films, so a noir line gets noir impostors.
Each quote keeps its top 20 cross-film neighbors. Decoy hardness for a candidate is then \(0.7 \cdot \text{semantic} + 0.3 \cdot \text{film}\): a line that is both on-topic and from an adjacent film is the hardest to rule out. A round stores the answer plus a ranked pool of its eight hardest decoys, at most one per film, with a near-duplicate guard that drops any candidate whose token sets overlap the answer (or another kept decoy) with Jaccard at or above 0.8. Rounds that cannot fill the pool are skipped rather than padded, and the final pool is capped at 150,000 rounds.
Difficulty and scoring
Each round's average decoy similarity becomes its difficulty band, assigned by terciles over the whole pool rather than fixed thresholds, so easy, medium, and hard are always equally populated. The game starts easy and adapts: a correct answer steps the next round's band up, a wrong one steps it down, clamped at the ends.
A correct answer scores
\[ \left( B + 50 \cdot \max\!\left(0,\ 1 - \tfrac{t}{10\,\text{s}}\right) \right) \cdot \min\!\left(1 + 0.1\,s,\ 2\right) \]where \(B\) is 30, 60, or 100 points by band, \(t\) is the answer time, and \(s\) is the streak of prior consecutive correct answers. The time bonus decays linearly to zero over ten seconds; the streak multiplier caps at 2. A wrong answer scores nothing and resets the streak. All of this is computed server-side; the client never learns the answer until it has committed a guess.
Serving rounds from the edge
The API is Hono on Cloudflare Workers with the pre-generated rounds in D1, Cloudflare's edge-hosted SQLite. Picking a uniformly random round without a full table scan uses a stored random key: every round carries a rand value in \([0, 1)\), the query draws a pivot and takes the first row with rand ≥ pivot on a (band, rand) index, wrapping to the start if nothing matches. That is an indexed seek instead of ORDER BY RANDOM(), and it stays uniform under difficulty, decade, and genre filters. The client sends the round and movie IDs it has already seen as exclusion lists, so a game avoids repeating rounds and films (the movie exclusion is dropped as a fallback if it would leave nothing to serve).
Choices are assembled per request from the ranked decoy pool and shuffled with Fisher-Yates driven by mulberry32 seeded with the round ID. Deterministic shuffling matters for the share feature: finishing a game produces a Wordle-style result block plus a challenge link carrying the exact round IDs, and whoever opens it replays the same rounds with the choices in the same order.
The client
The front end is SvelteKit with Svelte 5 runes for game state, deployed on Cloudflare Pages. While you read the reveal screen, the next round is already prefetched, so advancing is instant. Keys 1 through 3 answer, Enter advances, and personal stats (best score, best streak, games played) live in localStorage. There are no accounts and nothing is tracked server-side. A sibling project, unquote, works the opposite direction: instead of quizzing you on film dialogue, it lets you search it.