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.

Trouble with the embed? Open it full screen ↗  ·  type help in the game for the command set

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.

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.

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.

lighterman.html — intent resolution (abridged)
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.

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.

lighterman.html — trust, and how it gates a clue
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.

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.

lighterman.html — adaptive description
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.

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.

lighterman.html — the chain derives itself
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.

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.

// the fix

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.

node harness.js
  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

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.