// play first
Solve a death at Wapping
A lighterman — a Thames barge-hand — has been pulled from the river and ruled a drowning. You're Rachel Vane, asked quietly to be sure. Talk to people the way you would in life: ask the boy about the night, press Coyle about the alibi, reassure the widow. Watch the right-hand panel: the case file fills as you deduce, and the trust bars move as you treat people well or badly.
Two suspects won't give up what they know until you've earned a little trust — and pressing them too hard makes them close ranks. There's one true chain of deduction, but several orders to find it in, and the tone you take genuinely changes the path. Reach the end and Rachel names the killer without raising a hand: the case resolves, and no one is harmed in the solving of it.
// the contrast
What "parser" meant in 1982
The text adventures of the era were astonishing for their writing and merciless in their understanding. Under the hood, most worked the same way: split your input, find a verb in a fixed table, find a noun in another, ignore everything else. EXAMINE THE BRASS LANTERN CAREFULLY became EXAMINE LANTERN — "the", "brass", "carefully" all discarded. Say it a way the table didn't expect and you got the immortal "I don't understand that."
// verb-noun table (1982)
Two slots: a verb and a noun, matched against fixed lists. No notion of how you asked, only what. The world never changed based on your manner. Phrasing the parser didn't anticipate simply failed.
// intent + state (this)
Your sentence resolves to an intent, a target, a topic and a tone. Suspects carry trust that your manner moves. The narration tracks what you've seen. The same words can produce different outcomes depending on the state of the case.
None of this is a language model — it runs entirely offline in a single HTML file, and it's deliberately legible rather than a black box. It's four small systems working together.
- 1 · intent parserResolves natural phrasing to a structured command, including the tone you used.
- 2 · suspicion modelEach suspect remembers how you've treated them; trust gates what they'll say.
- 3 · adaptive narratorDescriptions change on repeat visits and nudge you toward threads you've ignored.
- 4 · clue graphTracks what you've connected; the solution derives itself, and accusations need the full chain.
// system 1
The parser: hearing the whole sentence
The job is to turn a free sentence into structure without a grammar engine. We scan for a verb anywhere in the input (not just the first word), resolve the target through synonym sets so Vesey, the landlord and the barman are one person, collect whatever's left as the topic, and — the part the old tables had no slot for — read the tone. Asking and pressing are the same intent with opposite manners.
function parse(raw) { const words = raw.toLowerCase().replace(/[^a-z0-9'?\s-]/g,"").split(/\s+/).filter(Boolean); let intent = null, tone = "neutral"; for (const w of words) for (const v in VERBS) if (VERBS[v].includes(w)) { if (v === "press") { intent = "ask"; tone = "hard"; } // same intent, else if (v === "reassure"){ intent = "ask"; tone = "soft"; } // opposite manner else intent = intent || v; } let target = null, topic = []; for (const w of words) { if (STOP.has(w)) continue; // drop "the", "about"... let matched = false; for (const id in SYN) if (SYN[id].includes(w)) { target = target || id; matched = true; } if (!matched && !VERBS_FLAT.has(w)) topic.push(w); // the rest is the subject } return { intent, target, topic: topic.join(" "), tone }; }
So press coyle about the alibi resolves to { intent: "ask", target: "foreman", topic: "alibi", tone: "hard" }. The plain EXAMINE LEDGER of 1982 still works — it's just no longer the only thing that does.
// system 2
The suspicion model: people who remember
This is where the tone earns its place. Each suspect has a trust value. A soft approach raises it; pressing lowers it; both are bounded so you can't farm it infinitely. And trust gates information — the mudlark boy and the grieving widow simply won't surrender their key facts to a stranger who hasn't shown them any decency.
function makeTrust(npcs) { const t = {}; for (const id in npcs) t[id] = npcs[id].startTrust; return { get: id => t[id], nudge(id, tone) { if (tone === "soft") t[id] = Math.min(3, t[id] + 1); else if (tone === "hard") t[id] = Math.max(-2, t[id] - 1); return t[id]; } }; } // a clue is findable only if its prerequisites AND trust are satisfied canFind(id, trust) { const cl = clues[id]; const reqOk = (cl.requires || []).every(r => known.has(r)); let trustOk = true; if (cl.trust_min != null) { const who = (cl.found_by.match(/ask (\w+)/) || [])[1]; trustOk = trust.get(who) >= cl.trust_min; } return reqOk && trustOk; }
The widow starts a little wary and only opens up at trust 2 — so you have to reassure her before she'll tell you the thing that breaks the case. The foreman starts hostile and stays that way; pressing him doesn't lower trust you need, it just confirms what kind of man he is. The trust bars in the side panel are this model, drawn live.
// system 3
The narrator: prose that pays attention
A static room description is the tell of a dead world — walk back in and read the identical paragraph and the illusion collapses. So descriptions come in variants keyed to how many times you've visited, and the narrator can append a nudge toward a thread you've left dangling. Stand on the stairs without yet clocking the tide line, and it starts to nag at you.
function describe(variants, visits, knownCount, hint) { const i = Math.min(visits, variants.length - 1); // first / second / nth visit let text = variants[i]; if (visits >= 1 && hint && knownCount < hint.threshold + 1) text += " " + hint.nudge; // foreshadow an ignored clue return text; }
It's a small thing, but it's the difference between a backdrop and a place. The narrator knows you've been here before, and it knows what you haven't yet thought to look at.
// system 4
The clue graph: deduction the game can check
Everything lands here. Each clue is a node that may require earlier clues, so the case unlocks in a sensible order rather than all at once. The resolving insight isn't a clue you stumble on — it's derived automatically the moment its supporting facts are all known, which models the click of working it out. And an accusation is refused unless that full chain exists: Rachel doesn't guess.
clues: {
tide_time: { found_by: "examine water@stairs", text: "...died after the tide turned..." },
boy_saw: { found_by: "ask boy about night", trust_min: 1, text: "...an oilskin on the stairs at five..." },
foreman_lie: { requires: ["tide_time", "boy_saw"], text: "...his alibi is a lie..." },
widow_knew: { requires: ["new_notes"], trust_min: 2, text: "...he meant to give it back and confess..." },
// derived: completes ITSELF once its parts are known
solution_chain: { requires: ["foreman_lie", "widow_knew"], derived: true,
text: "Finch repented and went to confess; Coyle followed him to the stairs..." },
}
Because the ending reads from the chain you assembled, the resolution is genuinely yours — the game can tell the difference between a hunch and a case, because it's holding the graph you built.
// proving it works
A bug only a robot would have the patience to find
An adventure game has a unique failure mode: it can be subtly unwinnable and still look perfect. A clue gated behind trust you can't raise, a conversation that dead-ends, a solution that never triggers — none of these throw an error. They just quietly waste the player's evening. So the case is tested like code. case.test.js proves the deduction chain is reachable from a cold start before any UI exists. engine.test.js checks each of the four systems. And harness.js stubs out the browser and plays the whole case to its solution through the real parser, asserting it reaches a verdict.
That harness caught a genuinely instructive bug — and it's the perfect one for this article, because it's exactly the failure the 1982 approach was riddled with. The word "notes" existed in two vocabularies at once: it was a synonym for the casefile command, and it was the topic you'd naturally use to ask the landlord about the notes. The verb scanner claimed it first, so the topic arrived empty, so the landlord refused to discuss the one thing he knew — politely, forever, with no error to explain why.
Remove the ambiguous word from the command vocabulary and let context disambiguate. A vocabulary collision is the oldest parser bug there is — and the value of a headless playthrough is that it hit the wall a real player would have, on every run, instead of one frustrated human eventually giving up and emailing you about it.
ok premature accusation is refused
ok tide clue found via 'examine the water'
ok trust gate blocks then opens (landlord notes)
ok witness clue found after kindness
ok widow gate (trust 2) blocks then opens
ok solution chain auto-derived
ok correct accusation closes the case
... 13 passed, 0 failed
// make it yours
Where to take it
The case is pure data, so the engine is a small authoring tool in disguise. Write a new clues graph and you have a new mystery with no engine changes. A few natural extensions: add an accusation with the wrong chain that yields a plausible but false ending, to reward careful play; let the foreman's hostility escalate so that pressing him too often locks a path, forcing a gentler route; give the narrator a second axis — time of day, Rachel's mood — for richer description; or add contradiction-detection, where presenting one suspect's clue to another opens a new line. The four systems stay separate, which is what makes any of these a local change rather than a rewrite.
And the door this leaves open: the parser is the obvious place a local language model could slot in — not to run the game, but to map messier sentences onto the same clean { intent, target, topic, tone } the rest of the engine already understands. The deterministic core stays testable; the model just widens the front door. That's a project for another day, and a private, offline one at that.