What I Built
It was 11:47 PM. A storm outside. Through the peephole, a soaked woman named Mara stood in the hall.
"Please. You already let me in."
I'd never seen her before. I didn't open the door.
She crossed the hall, knocked on my neighbor's door, and disappeared down the stairwell.
A few minutes later, I took the photo of my sister out of my pocket. Four digits were written across it.
They hadn't been there before.
Play After the Knock, a five-to-seven-minute horror story. Go in before reading how it works.
For Week 4's Ghost in the Machine, I built a game where choosing one path doesn't stop the other from happening.
The rejected path keeps running in the background, simulated by Gemma. Later, something it created can appear in the world you chose. You can inspect that evidence and trace it back to the event that produced it.
The engine can explain where the evidence came from. What it means is yours to discover.
Touch the digits and the game tells you which night they came from, and who wrote them. Underneath that, "View proof" opens the exact event: what the model proposed, what the validator decided, and what crossed over. After the ending, a map shows the timelines from your run and draws the threads.
Branching horror isn't new, and neither are timelines that remember or AI-written branching stories. I credit the ones I learned from below. What I wanted to try was the combination: rejected branches that keep simulating, intrusions that carry facts created after the fork, and provenance you can inspect for every one of them.
Demo
One complete night, start to finish. Includes an ending, so play first if you'd rather not see it.
- Play live in your browser: https://knock-live.onrender.com/ (the other nights are generated as you play by Gemma 4 31B on DigitalOcean serverless inference; if the model is ever unavailable, the page says so and offers recorded nights).
- Watch recorded nights: https://knock-replay.onrender.com/ (replays of real hosted runs, recorded with Gemma 4 31B; the model is not running there).
-
Play it locally:
scripts/setup && uv run knock-server(Ollama + Gemma 4 E2B, about 4 GB of GPU memory). -
No GPU:
uv run knock-server --replay-onlyplays recorded real runs. Each one is re-executed through the engine and checked against its recorded state hashes.
Code
vinmay
/
aftertheknock
A short horror story where the choice you didn't make keeps happening. Gemma 4 simulates the rejected timeline, and its events cross into yours as evidence you can trace to their source.
After the Knock
The night you didn't choose is still happening.
A short horror story where the choice you didn't make keeps happening in a hidden timeline, and its events start appearing in yours as evidence you can trace.
The idea: a horror game that simulates rejected choices as separate worlds and lets their recorded events manifest as evidence in the player's chosen reality.
Branching horror, timelines that remember, and AI-generated branching stories all exist already (see Prior art). This project combines three things: rejected branches that keep simulating, intrusions that carry facts created after the fork, and inspectable provenance for every intrusion.
It is 11:47 PM. A storm. A woman you have never met knocks on your door and says, "Please. You already let me in." Lock the door, and later you find four digits written on the thing in your pocket. Mara wrote them, in the night…
How I Built It
Why I couldn't just let the model narrate
The obvious version is: ask a model to "continue the night where the door was opened," then ask it to "describe something from that night bleeding into this one." It falls apart on the first detail. If the model writes "she scrawled 4182 on your ticket" in one call and "the numbers 4812" in the next, the story has no ground truth to come back to. Free-form narration can sound consistent without being consistent, and a horror game built on evidence needs the evidence to be real.
So the rule is: the model proposes, the engine commits.
The loop
flowchart TD
P[Player choice] -->|fork| C[Canonical timeline]
P -->|rejected option| S[Shadow timeline]
S -->|state, hint, JSON schema| G[Gemma 4: E2B local / 31B hosted]
G -->|proposed event| V{Validator}
V -->|rejected + reason| G
V -->|accepted| E[Engine commits event]
E --> S
E -->|one committed event| X[Intrusion]
X -->|committed facts| T[Authored crossing templates]
T --> CT[Crossing text]
SC[Scene context: location, who's with you, weather, power] --> A[Atmosphere model: Gemma]
A --> R{Rule checks}
R -->|optional sentence| CT
CT --> C
X -.->|origin event id| M[Map / trace]
Each choice splits the night. Your first choice (open or lock the door) creates your night and a shadow; your second (follow Mara down to the basement, or trust Teo and go up to the roof) splits again. The two nights you didn't choose run in the background as you read.
Each shadow event is proposed by Gemma in a schema-constrained call (Gemma 4 E2B locally, Gemma 4 31B in the hosted live game). A rejected proposal gets two more tries; after three rejections, a hand-written fallback is committed. The model gets the shadow's current state (who is where, who holds what, which doors are locked), one line of story beat ("Mara gets hold of the pocket object"), and nine allowed actions. It answers with an actor, an action, a target, any words spoken or written, and a short summary. It never writes state.
A deterministic validator then checks the proposal against that branch: does the actor exist here? Are they in the same room? Do they actually hold what they're trying to give? Is the place they're moving to adjacent and unlocked? If the answer is no, the model gets the reason and tries again. If the answer is yes, the engine derives the state change itself from the action's rules.
One proof chain, end to end
Real run, data/runs/replays/lock_roof_erase_1001.json (hosted, Gemma 4 31B; seed 1001, pocket "photo of my sister"; I locked the door). Events are shown as committed; the model's schema calls the to field recipient.
B.01 story You open the door. Mara steps in, dripping, and closes it behind her.
B.02 model {"actor": "mara", "action": "speak", "target": "apt", "text": "The photo in your pocket is fading."} -> ACCEPTED
B.03 model {"actor": "player", "action": "give", "target": "pocket", "to": "mara"} -> ACCEPTED
B.04 model {"actor": "mara", "action": "mark", "target": "pocket", "text": "8042"} -> ACCEPTED
objects.pocket.marks += "8042" preconditions: mara is present; mara holds pocket evidence: B.01, B.03
X1 intrusion B.04 -> A.04 post-fork fact: objects.pocket.marks += "8042"
A.04 crossing hand-written: "You take out the photo of your sister." / "8042" / "That wasn't there when you put it in your pocket. The handwriting isn't yours."
model's one sentence (rule-checked, written without seeing the shadow): "The smell of old copper fills the air."
hand-written, last: "You locked the door. Somewhere, you didn't."
The flashback the player sees for that crossing, rendered from those committed events:
You open the door. Mara steps in, dripping, and closes it behind her.
“The photo in your pocket is fading.” Mara says.
The other you hands the photo of your sister to Mara.
Mara writes “8042” across it.
This is where the numbers came from.
The digits were written by a model in a night you never played; every step is logged, and anyone can re-execute the run and check the hashes.
The impossible action
To test the validator on purpose, I told the model to have Mara give away the object she doesn't hold. Across 10 seeds it proposed exactly that 30 times (three attempts each). The validator rejected all 30 with R_HOLDS, and nothing changed state.
How a crossing reaches the screen
The story combines authored prose with model-generated events. A crossing is told in hand-written lines with the committed facts slotted in: the inscription, the object, whose handwriting, and a line about your own choice ("You locked the door. Somewhere, you didn't."). The model adds one optional sentence of atmosphere. The atmosphere prompt receives only scene context (where you are, who's with you, the weather, the power cut). It never sees the shadow log or the inscription. Rule checks screen what it writes; if no sentence passes within the retry limit, the crossing goes without it. The evidence view records the whole process.
The two crossings escalate. The first is discovered: someone else's handwriting, already there. The second is written while you watch: a message of one to three words, in your own handwriting. The engine forces the two crossings onto different objects.
The climax is authored, and I say so in the evidence view. After the second crossing, the other you (the one who made the opposite first choice) knocks at a door exactly like yours and speaks through it. What they say is assembled only from your own choices, inverted ("I opened it. I let her in."), from committed events of their night chosen by conditions on the log ("She told me, 'The photo in your pocket is fading.' I didn't know what she meant." is Mara's own committed line from the model, and appears only if the other you heard it), and from the story's backstory. A line with no supporting event is left out. A test checks every stated fact against the log.Ending spoilers: how the climax is assembled
Freedom vs. guarantees
Each other night has fixed beats (who must end up holding what, and that a message gets written); the model decides how it happens and every word that's said or written. Over the 24 local runs, 161 of 162 shadow steps were committed from the model's own proposal, and the second crossing was written in the player's own hand in 24 of 24 runs. In the hosted runs, the key reached the player's hand by Mara giving it; locally it was usually the player taking it.
Some steps are narrower than others, on purpose:
- Teo's door. The step where Teo opens his apartment for Mara accepts only his door as the target.
- Word marks. Each written message gets a seeded meaning (an apology, a warning, a promise, …), and the prompt asks for a verb; a phrase made only of names or things is rejected.
- The key. The step before the last mark guarantees only that you end up holding the brass key. Whether Mara gives it or you take it is the model's choice.
The model-commit rate is partly this high because these steps are narrower.
Same choices, different nights
The inscriptions and Mara's dialogue were generated by the model and committed by the engine.
| Seed | First crossing (digits) | Second crossing (message) | Mara, in the other night |
|---|---|---|---|
| 1001 | 8042 | DON'T OPEN IT | "The photo in your pocket is fading." |
| 1002 | 0921 | I TRUST HER | "You still carry her picture, don't you?" |
Both runs: locked the door, trusted Teo, went up to the roof (eval/results/hosted/runs/lock_roof_erase_1001.json, lock_roof_erase_1002.json).
What the numbers say
Local model: Gemma 4 E2B (gemma4:e2b, Q4_K_M), 24 live playthroughs covering every fork path; raw logs in eval/results/runs/.
| Measurement | Result |
|---|---|
| Unintended state crossings | 0 |
| Narrator saw unreleased shadow text | 0 |
| Intrusions revealing a post-fork fact | 48/48 (100%) |
| Intrusions with valid origin event | 48/48 (100%) |
| Replays reproducing every state hash | 24/24 (100%) |
| Shadow events committed from the model | 161/162 (99%) |
| Shadow events from hand-written fallback | 1/162 (1%) |
| Proposals rejected by the validator | 21/182 (12%) |
| Model call latency p50 / p95 | 1.85 s / 2.97 s |
Narration and intrusion accuracy were not hand-labeled for this version.
The variation across seeds and the per-step freedom table are in the README results.
Hosted model, reported separately. The live site runs Gemma 4 31B on DigitalOcean serverless inference. In a separate run of 8 playthroughs (2 per route pair): the same invariants held (0 unintended crossings, 0 leaks, 16/16 crossings with a post-fork fact and a valid origin, 8/8 replays reproducing), 54 of 54 shadow steps came from the model, the second crossing was in the player's own hand 8 of 8 times, model calls took 1.42 s p50 / 1.81 s p95, and the playthroughs cost $0.00906 in total (about $0.00113 each). Two seeds per path is too few to compare models; it shows only that the hosted mode works end to end.
What failed, and what I cut
- The first benchmark pass committed only 20 of 35 shadow steps from the model. The model kept putting the recipient in the
targetfield. Puttingsummaryfirst in the schema (so it states the event before filling fields), renamingtotorecipient, and listing each person's reachable rooms raised that to 34 of 35. - The model loves certain numbers. Early runs kept producing 1234, 4321 and then a lot of 48xx. Trivial codes are now rejected, and each run gets a seeded hint about what the number means (a year, a date, a locker combination). The digits are still the model's.
- Word marks were word salad at first: "FLOOR COOKIE C", "BASSMENT STATIC", "R O O F B A S E M E N". Then they were readable but empty ("MARA", "KEY TAG"). A word mark is now one to three words from a fixed vocabulary of the building, its people and plain words; it can't be a place name, end on a dangling word, or be only names or things. Each one gets a seeded meaning, and the prompt asks for a verb. The model still picks the words.
- Spoken lines sometimes got cut off at the schema's length limit ("…something else I don"). The validator now rejects a line that stops mid-sentence and asks for fewer words.
- A beat that said "Someone steps into unit 07" made the model pick Teo, who wasn't in the basement. Naming who could act ("Mara, or you") fixed it: that step is now committed from the model in 6 of 6 local runs.
- My first version let the model narrate each crossing in full. The prose was flat and samey, and the moment that carries the whole game deserved better, so the crossings became hand-written lines around model-generated facts.
- The map used to appear before the ending and gave the other night away; it now opens only after the ending, and the live server refuses the map and the full log until then.
- Testers said they couldn't connect to the story: Mara had a goal and I was a spectator. The rewrite gave the player a wound (the night in March they didn't answer Teo's knock), made the second choice about whom to trust, and rebuilt the ending around a confrontation.
- The small model loves horror clichés ("HELP ME", "DON'T LOOK BACK"). They are on a banned list and get rejected like any impossible action.
- When the game first went live on Render, an old key could list models but not run them. The site said "live" while every event quietly fell back to hand-written text. The health check now runs a real one-token completion.
- Cut: a premise generator, more than two forks, more than three characters.
Why Does Open Innovation Matter?
- The shadow world is many quiet calls. A playthrough makes about 10 model calls on average (about 6,000 tokens) that the player never sees directly, most of them in a world the player never visits. Locally, there's no per-call API charge. Hosted, it measured about $0.0011 per playthrough.
- Pinned weights. I tuned the validator, prompts and fallback rates against one exact model file. If a hosted model changed underneath, the story's fallback rate would drift without anyone noticing. With open weights, the local mode can always be checked against the same file.
- Inspectable all the way down. Model, prompts, proposals, rejections, commits: all of it is on your machine, and the evidence view shows it after the ending.
- It runs on a seven-year-old consumer GPU. A GTX 1660 Ti, 6 GB, no tensor cores.
Prize Categories
Gemma. Gemma is the engine of every night you didn't choose. Each shadow event is a Gemma proposal in a strict JSON schema, checked by a deterministic validator before anything is committed. I used two sizes of the same open-weight family:
- Gemma 4 E2B, locally, through Ollama on a seven-year-old GTX 1660 Ti (6 GB). All development and the 24-run evaluation ran on it: 161 of 162 shadow steps committed from the model, model calls 1.85 s p50. The exact model file is pinned by digest in MODELS.md, so the numbers are reproducible.
- Gemma 4 31B, hosted, for the public live game: 54 of 54 shadow steps from the model in the hosted evaluation, 1.42 s p50. Gemma also writes the only free-form prose in a crossing: one atmosphere sentence, generated without seeing the shadow and screened by rule checks.
DigitalOcean. The live game's other nights are generated by Gemma 4 31B on DigitalOcean serverless inference, through its compatible endpoint.
Render. Render is the game's AI runtime: a Python service that runs the shadow simulation loop, calls the model, validates and commits every event, enforces the crossings, and serves the front end, deployed from a render.yaml blueprint. A second Render service hosts the static replay site, where recorded nights are re-executed and hash-checked in your browser.
Prior art and credit
Branching horror and alternate timelines are well-trodden ground, and this is related work, not a first:
- Undertale, Slay the Princess and Until Dawn: games that remember, loop or branch on your choices.
- ECHO: rewriting reality fractures the world into alternate branches, and one character remembers the erased timelines.
- BRING: LLM role-play with branch create, switch and merge for alternate storylines.
- WHAT-IF (arXiv 2412.10582) and Narrative Studio (arXiv 2504.02426): LLM-driven branching narrative.

Top comments (2)
This is so cool! I am a big fan of Undertale and Slay the Princess, I love games which remember your choices and they matter.
Thank you so much! So am I. I was inspired by them and in some way I wanted to show the essence of those games in my own way 😁