{"slug": "writing-mystery-games-in-vanilla-js-interrogation-systems-trust-trackers-and-6", "title": "Writing Mystery Games in Vanilla JS: Interrogation Systems, Trust Trackers, and 6-Ending Medical Thrillers", "summary": "An indie developer built two narrative mystery games, Echo Chamber — An Interrogation Mystery and Flatline — A Medical Thriller, in vanilla JavaScript with no game engine or dependencies, each under 20 KB. The games use a state machine tracking suspicion/evidence or trust/doubt to drive branching dialogue and multiple endings, with features like typewriter text, Web Audio heartbeat synthesis, and SVG EKG animation.", "body_md": "*AI Disclosure: This article was written with AI assistance. All games mentioned were built using AI-assisted development tools.*\n\nWhen indie devs think \"browser game,\" they think platformers, idle clickers, or puzzles. But look at the itch.io HTML5 top charts: narrative-driven mystery and horror games dominate. FORGOTTEN. Exorcist Candy. Short, story-heavy experiences that load in a second and stay with you for hours.\n\nThe technical question is: how do you build a narrative game without a game engine? No Unity. No Unreal. No Twine. Just an HTML file, a `<script>`\n\ntag, and whatever you can fit in under 20 KB.\n\nI built two such games — **Echo Chamber — An Interrogation Mystery** (14 KB) and **Flatline — A Medical Thriller** (19 KB) — and this article breaks down the architecture: dialogue trees, trust/doubt state tracking, multi-ending logic, Web Audio heartbeat synthesis, and SVG EKG animation. All in vanilla JavaScript, no dependencies, no frameworks.\n\nBoth games share the same skeleton:\n\n```\nindex.html\n├── <style>    (8 KB: CSS, monospace font, CRT glitch effects)\n├── <body>     (text + choices + HUD + SVG elements)\n└── <script>   (11 KB: game state + dialogue + endings + audio + EKG)\n```\n\nOne file. One HTML document. No build step. No npm install. You open it in a browser and it works. This is the strongest argument against engine dependency: a narrative game doesn't need a render loop running at 60 fps. It needs text, choices, and state. The browser already has all three.\n\nThe core of both games is an invisible state machine that tracks two variables. Players never see the raw numbers — they see the consequences.\n\n``` js\nlet suspicion = 0, evidence = 0, round = 0;\n\nfunction r1_alibi() {\n  suspicion++;\n  updateHud();\n  type(\"\\\"At 11 PM?\\\" She considers. \\\"I was walking home. Alone...\\\"\", () => {\n    evidence++;\n    updateHud();\n    type(\"You note: she answered a question you didn't ask.\", () => round2());\n  });\n}\n```\n\nEvery choice bumps one of two meters. The HUD renders them as ASCII bars:\n\n```\nSUSPICION: ███░░  EVIDENCE: 2/3  ROUND: 1/3\n```\n\nThe key design insight: **suspicion and evidence are not opposites**. You can push hard (raising suspicion) and still gather evidence. You can be gentle (low suspicion) and learn nothing. The player must balance aggression against information — a trade-off that creates natural tension without any explicit \"difficulty\" setting.\n\nFlatline mirrors this with a different emotional axis:\n\n``` js\nlet trust = 0, doubt = 0, round = 0;\n```\n\nTrust goes up when you believe the patient. Doubt goes up when you question the nurse. The endings depend on which is higher — not which is \"correct,\" because neither game has a single correct path. Trust the patient and he lives; doubt the nurse and you miss the conspiracy. Doubt the patient and you save him from a rigged defibrillator; trust the nurse and she leads you into a trap.\n\nThe state is always visible to the player via the HUD, but the *meaning* of each state combination is only revealed at the ending. This is the narrative equivalent of a roguelike — you learn the system by failing it.\n\nBoth games render dialogue one character at a time:\n\n```\nfunction type(text, cb) {\n  typing = true; skip = false; T.innerHTML = '';\n  let i = 0;\n  const iv = setInterval(() => {\n    if (skip) { T.textContent = text; clearInterval(iv); typing = false; cb && cb(); return; }\n    if (i < text.length) { T.insertBefore(document.createTextNode(text[i]), cursor); i++; }\n    else { clearInterval(iv); typing = false; cb && cb(); }\n  }, 36);\n  document.onclick = () => { if (typing) skip = true; };\n}\n```\n\n36 milliseconds per character. Not 30, not 50. 36 is the sweet spot I found through playtesting — fast enough that you don't get bored, slow enough that you feel the weight of each word. The click-to-skip is critical: repeat players want to rush through known dialogue, and forcing them to wait would kill replay value.\n\nThe callback architecture (`cb`\n\n) is what makes branching possible. Each `type()`\n\ncall ends with a callback that either shows choices or advances to the next scene. No async/await, no promises, just nested callbacks — and for a game this size, that's all you need.\n\nBoth games have exactly 6 endings. Here's the math:\n\nEcho Chamber tracks `suspicion`\n\n(0-5) and `evidence`\n\n(0-3). That's 24 possible states, but most are functionally identical. The endings check thresholds:\n\n```\nfunction r3_direct() {\n  if (suspicion >= 3) {\n    end(\"...ENDING: The Confession\");\n  } else if (evidence >= 2) {\n    end(\"...ENDING: The Wrong Suspect\");\n  } else {\n    end(\"...ENDING: The Smiling Woman\");\n  }\n}\n```\n\nThe branching happens inside the final round's choice functions, not in a separate \"ending resolver.\" This is important: the ending is determined by *which choice you made in round 3* combined with *what state you arrived with*. Two players who make the same final choice can get different endings based on their earlier decisions.\n\nFlatline does the same with `trust`\n\nand `doubt`\n\n, but adds a twist: some endings require *both* to be above threshold:\n\n```\nif (trust >= 2 && doubt >= 2) {\n  end(\"...ENDING: The Signal\");\n} else {\n  end(\"...ENDING: The Uncertainty\");\n}\n```\n\nThis creates a \"golden path\" — the best ending requires you to both trust the patient AND question the system. Players who blindly trust everything miss it. Players who blindly doubt everything miss it. Only players who hold both in tension find it.\n\nNo audio files. No sound assets. Both games synthesize their heartbeat in real-time:\n\n```\nfunction startHeartbeat(bpm) {\n  if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();\n  if (heartbeat) clearInterval(heartbeat);\n  heartbeat = setInterval(() => {\n    const osc = audioCtx.createOscillator();\n    const gain = audioCtx.createGain();\n    osc.frequency.value = 55;    // low thump\n    osc.type = 'sine';\n    gain.gain.setValueAtTime(0.12, audioCtx.currentTime);\n    gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);\n    osc.connect(gain); gain.connect(audioCtx.destination);\n    osc.start(); osc.stop(audioCtx.currentTime + 0.15);\n  }, 60000 / bpm);\n}\n```\n\n55 Hz sine wave, 150ms decay. That's a heartbeat. The BPM is tied to the tension level — in Echo Chamber, it speeds up as suspicion rises. In Flatline, it speeds up as the patient's vitals deteriorate.\n\nThe beauty of this approach: zero bytes of audio assets, zero loading time, and the heartbeat can react to game state in real time. Try doing that with an MP3 file.\n\nFlatline adds a `flatlineTone()`\n\n— a 440 Hz sine wave with a 2-second decay — for the death endings. That single sustained tone, after the heartbeat has been thumping for 10 minutes, is more effective than any horror soundtrack.\n\nFlatline has something Echo Chamber doesn't: a live EKG (electrocardiogram) line scrolling across the top of the screen. It's pure SVG:\n\n``` js\nfunction drawEKG(bpm) {\n  const w = 800, h = 60, mid = 30, amp = 18;\n  let d = `M 0 ${mid}`;\n  const beatLen = 800 / (bpm / 60 * 2);\n  for (let x = 0; x < w; x += 2) {\n    const phase = (x + ekgOffset) % beatLen;\n    let y = mid;\n    if (phase < beatLen * .1) y = mid;\n    else if (phase < beatLen * .15) y = mid - amp * 1.5;   // P wave\n    else if (phase < beatLen * .2) y = mid + amp * 3;      // R spike\n    else if (phase < beatLen * .25) y = mid - amp * 2;     // S wave\n    else if (phase < beatLen * .3) y = mid;\n    else y = mid + Math.sin(phase * .05) * 1.5;            // baseline noise\n    d += ` L ${x} ${y.toFixed(1)}`;\n  }\n  EKG.setAttribute('d', d);\n  ekgOffset += 3;\n}\n```\n\nThis generates a realistic QRS complex — the P wave, the R spike, the S wave — procedurally. The BPM changes the beat length, so when the patient's heart rate goes from 60 to 120, the EKG visibly accelerates. When the patient flatlines, `stopEKG()`\n\nreplaces the path with a flat line:\n\n```\nfunction stopEKG() {\n  clearInterval(ekgTimer);\n  EKG.setAttribute('d', 'M 0 30 L 800 30');\n}\n```\n\nThat visual — a jagged line going perfectly flat — is the most powerful moment in the game. And it's 6 lines of code.\n\nBoth games use CSS classes and inline styles to create CRT glitch effects:\n\n```\nfunction flicker(intensity, dur) {\n  S.style.opacity = intensity;\n  setTimeout(() => S.style.opacity = 0, dur);\n}\nfunction glitchOn(dur) {\n  document.body.classList.add('glitch');\n  setTimeout(() => document.body.classList.remove('glitch'), dur);\n}\n.glitch { animation: glitch .3s steps(2) infinite; }\n@keyframes glitch {\n  0% { transform: translate(0); }\n  25% { transform: translate(-1px, 1px); }\n  50% { transform: translate(1px, -1px); }\n  75% { transform: translate(-1px, -1px); }\n  100% { transform: translate(1px, 1px); }\n}\n```\n\nThe static overlay is a repeating linear gradient — 3px stripes of 1.5% opacity. Almost invisible, but when it flickers on during a tense moment, the screen feels *wrong*. The whole effect is under 20 lines of CSS and 10 lines of JavaScript. No WebGL shaders, no canvas manipulation, just CSS transforms and opacity toggles.\n\nThese two games — **Echo Chamber — An Interrogation Mystery** and **Flatline — A Medical Thriller** — prove something the indie community often forgets: narrative games don't need engines. They need a typewriter effect, a state machine, and the courage to let text carry the weight.\n\nThe entire codebase for both games, combined, is 34 KB. That's smaller than the logo image on most game studio websites. It loads in under 200ms on a 3G connection. It runs on any browser that supports `setInterval`\n\n— which is all of them.\n\nIf you're a writer who wants to make games, don't learn Unity. Don't learn Godot. Learn 50 lines of JavaScript and build a dialogue tree. The medium is waiting for you.\n\n**Play both games (and 7 others) in the August Sale — 45% off, ends August 31:**\n\n[https://aguier.itch.io/sale-hub](https://aguier.itch.io/sale-hub)\n\n*All games include AI Use Disclosure: AI Assisted labels. Built with AI-assisted development tools.*", "url": "https://wpnews.pro/news/writing-mystery-games-in-vanilla-js-interrogation-systems-trust-trackers-and-6", "canonical_source": "https://dev.to/aguier/writing-mystery-games-in-vanilla-js-interrogation-systems-trust-trackers-and-6-ending-medical-5efg", "published_at": "2026-08-04 02:02:08+00:00", "updated_at": "2026-08-04 03:11:03.897589+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["Echo Chamber", "Flatline", "itch.io"], "alternates": {"html": "https://wpnews.pro/news/writing-mystery-games-in-vanilla-js-interrogation-systems-trust-trackers-and-6", "markdown": "https://wpnews.pro/news/writing-mystery-games-in-vanilla-js-interrogation-systems-trust-trackers-and-6.md", "text": "https://wpnews.pro/news/writing-mystery-games-in-vanilla-js-interrogation-systems-trust-trackers-and-6.txt", "jsonld": "https://wpnews.pro/news/writing-mystery-games-in-vanilla-js-interrogation-systems-trust-trackers-and-6.jsonld"}}