Back to Blog
Engineering

How to Build a Series of Static Pages From One JSON File, and Make the Build Refuse Bad Data

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.

FieldPlaceholderWhat the build requires
number1Numbers run from 1 to the count of cases, with no gap and no repeat.
slugthe-example-fileLowercase letters and digits in words joined by hyphens. The case's address is its number, a hyphen and the slug.
titleThe Example FileMust be present.
placeExample Hall, an invented townMust be present.
ledeOne paragraph that sets the scene.Must be present.
seconds90Optional. The page and the card fall back to 90.
suspectspat, Pat Placeholder; sam, Sam Sample; each with a roleAt least two, no two sharing an id, and no name that a suspect in another case already has.
exhibitsA list of papers, each with a label and a kind the page knows how to draw: statements, text, mono for a typed document, or imageAt least one statements exhibit, each holding as many statements as there are suspects, and no em dash inside a statement.
question, promptThe heading and line above the namesOptional. Without a question, the page asks “Who is lying?”
liarsamOne of the suspect ids.
whyOne explanation per suspect idAn entry for every suspect.
closingThe 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:

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 readsIt does not read
The case's title, lede and closingThe place
Each exhibit's title and paragraphsEach suspect's name and role
The text of every statementA statement's speaker label
A typed document's text, and an image's captionAn exhibit's label and stamp, and an image's alt text
Every why explanationThe 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:

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

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.

Brandon Wigley

Founder of Wigley Studios. Building developer tools since 2018.

Previous: Free Browser Mystery Games: The 90-Second Case All Articles