Every case in The 90-Second Case, the free one-screen mysteries on this site, comes out of one data file. Its page, its share card and its sitemap entry are built from that file by one command, so adding a case is writing one JSON object and running the command. The deploy that follows ships what it wrote.
Most of the command is a refusal. It will not build a series whose numbering has a hole, a case with two liars or none, a suspect with no explanation, a year that would date the archive, or a name that already belongs to a suspect in another case. Only when every case passes does it write pages, render cards and touch the sitemap. The lesson travels to any small static site: put the rules where the build can refuse, and make the build the only way in.
The series from a player's side is in Free Browser Mystery Games: The 90-Second Case, and the design rules behind every case are in Caught by Paper, Never by Motive. This is the plumbing, and it quotes nothing from a published case: the example below is invented placeholder text.
One File That the Browser and the Build Both Read
The data file is cases.js: a single assignment, window.CL_CASES = [ ... ];, with plain JSON between the brackets, one object per case. The browser loads it with an ordinary script tag, and the page's own script picks a case out of the array. It takes the number in data-file on the page's main element first. The series page has no such attribute, so there it looks for a ?file=N query, and if neither names a case that exists, it plays the newest one.
The build is Python, and it never executes the data file. Its loader reads the file as text, finds the array after window.CL_CASES = with one regular expression, and hands it to the standard JSON parser. If the expression finds nothing, the loader stops and names the file it was reading.
The price is one rule: inside the brackets, write JSON, not JavaScript. A trailing comma, a comment, a single-quoted string or an unquoted key is legal in a script and fatal to a JSON parser, so the browser would take a file the build refuses. That is the right way round, because the mistake stops on the author's machine. A comment above the assignment is fine, but the expression is anchored to the end of the file, so the array and its semicolon have to come last.
The Shape of One Case
Here is every field, filled with placeholder text. This is not a published case, and its place is invented.
| Field | Placeholder | What the build requires |
|---|---|---|
number | 1 | Numbers run from 1 to the count of cases, with no gap and no repeat. |
slug | the-example-file | Lowercase letters and digits in words joined by hyphens. The case's address is its number, a hyphen and the slug. |
title | The Example File | Must be present. |
place | Example Hall, an invented town | Must be present. |
lede | One paragraph that sets the scene. | Must be present. |
seconds | 90 | Optional. The page and the card fall back to 90. |
suspects | pat, Pat Placeholder; sam, Sam Sample; each with a role | At least two, no two sharing an id, and no name that a suspect in another case already has. |
exhibits | A list of papers, each with a label and a kind the page knows how to draw: statements, text, mono for a typed document, or image | At least one statements exhibit, each holding as many statements as there are suspects, and no em dash inside a statement. |
question, prompt | The heading and line above the names | Optional. Without a question, the page asks “Who is lying?” |
liar | sam | One of the suspect ids. |
why | One explanation per suspect id | An entry for every suspect. |
closing | The case's last line. | Must be present. |
Two of those fields are the answer, which is why no published case's JSON ever appears in an article: liar names the suspect who lies, and why holds an explanation for every name a player can click. Every name, and not only the liar's, because a wrong pick shows the explanation for the name you chose before it shows the liar's. A missing entry would print the word undefined on the result card, so the build refuses the case instead.
What the Command Refuses
With --check, the builder validates the series and stops, writing nothing. On October 2, 2026 it printed cases.js OK: 3 case(s). On bad data it prints every problem rather than stopping at the first, one line each, and exits with an error. The rules, and what each one protects:
- Numbering. From 1 to the number of cases, with no gap and no repeat. A page finds its case by number, and its series line is arithmetic (“case 1 of 3”), so a gap would print “case 4 of 3” and a repeat would leave two pages playing one case.
- Fields. Nine keys in every case: slug, title, place, lede, exhibits, suspects, liar, why and closing. The slug becomes part of the address, so it must be lowercase letters and digits joined by hyphens.
- People. At least two suspects with unique ids, and the liar must be one of those ids. That single test refuses a case with no liar, a liar from outside the case, and a case that tries to name two.
- Explanations. A
whyfor every suspect id, for the reason above. - Statements. At least one statements exhibit, each holding exactly as many statements as there are suspects. It counts them. Who gave each one is still the writer's to check.
- Years. No year from 1800 to 2099 in the text the check reads, because the archive line near the top of every case page says “Years redacted per release agreement”. Exactly which text that is comes next.
- Em dashes in speech. None inside a statement. A statement stands for something a person said out loud, and nobody says a dash. Text that is read rather than said is not checked.
- Names. No suspect may share a name with a suspect in another case. Each case brings a new cast, and in the Cold Ledger universe bringing a character back is a deliberate step with its own continuity check, so an accidental repeat would read as a return nobody planned.
- Share cards. Two proofs that no card carries an exhibit's words, described below. They run last, and only on a series that passed everything else, because they read fields the other checks guarantee.
Why these are code and not a checklist
Most of these rules are also written down in prose, in the brand's universe notes and the series plan. Prose has to be read at the right moment by someone who has just finished a case and wants to ship it. A check runs every time, over every case in the series and not only the new one, and says what failed.
What the Year Check Reads, Exactly
The rule is a regular expression, \b(?:18|19|20)\d{2}\b: a four-digit number from 1800 to 2099, standing alone as a word. The word boundaries cut both ways. “(1962)” is refused. “The 1990s” passes, because the s joins the digits into a longer word. A house number like 2045 is refused, because a pattern cannot tell a year from an address. So the honest description of the rule is the list of fields it reads:
| The year check reads | It does not read |
|---|---|
| The case's title, lede and closing | The place |
| Each exhibit's title and paragraphs | Each suspect's name and role |
| The text of every statement | A statement's speaker label |
| A typed document's text, and an image's caption | An exhibit's label and stamp, and an image's alt text |
Every why explanation | The question and prompt above the names, and the slug |
Everything in the right-hand column can reach a player too, on the page, in its address or on its card, and those fields are left to the writer. If you copy the pattern, choose the list on purpose and keep it next to the check, because the list is the rule.
One Template, Every Page
With the data accepted, the build fills one HTML template's {{PLACEHOLDER}} tokens for every page, among them the title and description, the address, the share-card image, the heading, the lede, and the attribute that tells the page's script which case to play. The series page gets series-level titles, no data-file, so it plays the newest case, and a count: “3 cases so far” on October 2, 2026. Each case page gets its own titles, its number in data-file and its position in the run: “case 1 of 3” on the first.
The filling function has one guard, and it is the part worth copying first. After replacing every token it was given, it searches the result for any token still standing, and if it finds one it stops the build and prints the leftover names. A placeholder the code does not fill cannot reach a page as a raw {{LEDE}}.
Two smaller habits make a rebuild safe. The site's deploy stamps a content hash on every script and stylesheet link, and the template has none, so the build carries each page's existing hashes across before comparing. Then, when the new page matches the one on disk, it leaves the file alone, so a rebuild changes only the pages whose content changed.
Share Cards: The Cover, Never a Page From Inside
A share card is the image a chat app or a social network shows for a pasted link, and for a mystery it is the one part of a case a stranger sees before deciding to play. So the design rule is that a card shows the cover of the file, never a page from inside it: the number, title and place, a redaction bar for the date, the exhibit letters, the number of statements and the time. The series page gets a card of its own, an index of the newest titles, and the site's Play page shares that one.
Two proofs hold the rule, and both run inside the validation step, so --check runs them too:
- The blind render. Every card's text is built twice, once from the real cases and once from copies in which every string inside every exhibit, apart from its kind, is replaced by the same marker. A card that comes out different was reading an exhibit, and the build refuses it.
- The line search. Each card's filled template is searched for every line of ten characters or more from any typed-document exhibit, other than a line that is only a case's title or place. The message names the card and the exhibit, never the line, so a failed build cannot leak what it caught.
Then it renders. Each card is a small HTML page, 1200 by 630 pixels, opened in headless Chromium through Playwright and screenshotted. Before the screenshot the build measures it, and if the card's own text runs under the tilted paper, the paper clips its text, or the column spills past the card's foot, the build stops there with nothing written for that card. A title too long for the card fails on the author's machine, not in someone's feed.
Each card's two hashes, one of the HTML it was rendered from and one of the image, go into a small manifest, and each page links its card as og.png?v= followed by the first eight characters of the source hash. The build updates the Play page's link the same way. That part is about caching: the site serves the case pages with no-cache and the card images with max-age=31536000, which is a year. A changed card gets an address no cache has seen, and the change rides in the page, which every visit revalidates. A flag, --no-og, skips the cards when nothing a card shows has changed. If something a card shows did change, a title for instance, the hashes no longer match and the site's test suite fails until the cards are rebuilt.
The Sitemap, and the Only Way In
Last, the sitemap. Any case address missing from sitemap.xml gets an entry before the closing tag, dated the day of the build, monthly, priority 0.7. Entries already there are left alone, and the file is written only when something was added.
None of this helps if the data file can reach the live site around the command, and the likeliest route is a quick fix: a typo corrected in cases.js and deployed without running the build. So the same validation also runs in the site's test suite, against the same data file, and the site's deploy runs that suite before it uploads anything. The edit that skipped the command meets the same refusal on its way out, and if it changed anything a card shows, the stale card fails the suite as well.
What the build does not judge
Whether a case is fair, whether its contradiction can be read on one screen in ninety seconds, or whether the paper alone catches the liar. Those are design questions, and the rules for them are in Caught by Paper, Never by Motive. The build's guarantee is narrower: a case that reaches the site is shaped correctly, passes the series' checks on years, speech and names, and gives every page and card what it needs. That is a smaller claim than “the case is good”, and it is a true one.
The Pattern, for Any Small Static Site
- Keep one file that every reader shares. JSON inside a script wrapper here; a file the page fetches would do. What matters is that no second copy can drift.
- Validate everything before writing anything. Collect every problem in one pass, and make each message say which record failed and why.
- Describe each rule as narrowly as its code. Say which fields a check reads, and keep that list beside the check.
- Refuse at the last step too. An unfilled token, or an image whose text would overflow, stops the build rather than shipping.
- Make the build the only way in. Run the same validation in the test suite your deploy already runs, so a hand edit meets it anyway.
The same idea runs through Why Contract-First Development Is Quietly Winning, where agreeing on an interface's shape before writing code lets a drift between the spec and the code fail the build rather than production, and through Inside Brand Token Studio, which defines a visual language once and exports it to CSS, Tailwind or JSON. One source, and a build that says no. If you would rather see the output than the plumbing, the newest case takes ninety seconds.