cd /news/developer-tools/writing-mystery-games-in-vanilla-js-… · home topics developer-tools article
[ARTICLE · art-85484] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Writing Mystery Games in Vanilla JS: Interrogation Systems, Trust Trackers, and 6-Ending Medical Thrillers

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.

read8 min views1 publishedAug 4, 2026

AI Disclosure: This article was written with AI assistance. All games mentioned were built using AI-assisted development tools.

When 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.

The 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>

tag, and whatever you can fit in under 20 KB.

I 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.

Both games share the same skeleton:

index.html
├── <style>    (8 KB: CSS, monospace font, CRT glitch effects)
├── <body>     (text + choices + HUD + SVG elements)
└── <script>   (11 KB: game state + dialogue + endings + audio + EKG)

One 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.

The core of both games is an invisible state machine that tracks two variables. Players never see the raw numbers — they see the consequences.

let suspicion = 0, evidence = 0, round = 0;

function r1_alibi() {
  suspicion++;
  updateHud();
  type("\"At 11 PM?\" She considers. \"I was walking home. Alone...\"", () => {
    evidence++;
    updateHud();
    type("You note: she answered a question you didn't ask.", () => round2());
  });
}

Every choice bumps one of two meters. The HUD renders them as ASCII bars:

SUSPICION: ███░░  EVIDENCE: 2/3  ROUND: 1/3

The 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.

Flatline mirrors this with a different emotional axis:

let trust = 0, doubt = 0, round = 0;

Trust 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.

The 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.

Both games render dialogue one character at a time:

function type(text, cb) {
  typing = true; skip = false; T.innerHTML = '';
  let i = 0;
  const iv = setInterval(() => {
    if (skip) { T.textContent = text; clearInterval(iv); typing = false; cb && cb(); return; }
    if (i < text.length) { T.insertBefore(document.createTextNode(text[i]), cursor); i++; }
    else { clearInterval(iv); typing = false; cb && cb(); }
  }, 36);
  document.onclick = () => { if (typing) skip = true; };
}

36 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.

The callback architecture (cb

) is what makes branching possible. Each type()

call 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.

Both games have exactly 6 endings. Here's the math:

Echo Chamber tracks suspicion

(0-5) and evidence

(0-3). That's 24 possible states, but most are functionally identical. The endings check thresholds:

function r3_direct() {
  if (suspicion >= 3) {
    end("...ENDING: The Confession");
  } else if (evidence >= 2) {
    end("...ENDING: The Wrong Suspect");
  } else {
    end("...ENDING: The Smiling Woman");
  }
}

The 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.

Flatline does the same with trust

and doubt

, but adds a twist: some endings require both to be above threshold:

if (trust >= 2 && doubt >= 2) {
  end("...ENDING: The Signal");
} else {
  end("...ENDING: The Uncertainty");
}

This 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.

No audio files. No sound assets. Both games synthesize their heartbeat in real-time:

function startHeartbeat(bpm) {
  if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  if (heartbeat) clearInterval(heartbeat);
  heartbeat = setInterval(() => {
    const osc = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    osc.frequency.value = 55;    // low thump
    osc.type = 'sine';
    gain.gain.setValueAtTime(0.12, audioCtx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
    osc.connect(gain); gain.connect(audioCtx.destination);
    osc.start(); osc.stop(audioCtx.currentTime + 0.15);
  }, 60000 / bpm);
}

55 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.

The beauty of this approach: zero bytes of audio assets, zero time, and the heartbeat can react to game state in real time. Try doing that with an MP3 file.

Flatline adds a flatlineTone()

— 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.

Flatline has something Echo Chamber doesn't: a live EKG (electrocardiogram) line scrolling across the top of the screen. It's pure SVG:

function drawEKG(bpm) {
  const w = 800, h = 60, mid = 30, amp = 18;
  let d = `M 0 ${mid}`;
  const beatLen = 800 / (bpm / 60 * 2);
  for (let x = 0; x < w; x += 2) {
    const phase = (x + ekgOffset) % beatLen;
    let y = mid;
    if (phase < beatLen * .1) y = mid;
    else if (phase < beatLen * .15) y = mid - amp * 1.5;   // P wave
    else if (phase < beatLen * .2) y = mid + amp * 3;      // R spike
    else if (phase < beatLen * .25) y = mid - amp * 2;     // S wave
    else if (phase < beatLen * .3) y = mid;
    else y = mid + Math.sin(phase * .05) * 1.5;            // baseline noise
    d += ` L ${x} ${y.toFixed(1)}`;
  }
  EKG.setAttribute('d', d);
  ekgOffset += 3;
}

This 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()

replaces the path with a flat line:

function stopEKG() {
  clearInterval(ekgTimer);
  EKG.setAttribute('d', 'M 0 30 L 800 30');
}

That visual — a jagged line going perfectly flat — is the most powerful moment in the game. And it's 6 lines of code.

Both games use CSS classes and inline styles to create CRT glitch effects:

function flicker(intensity, dur) {
  S.style.opacity = intensity;
  setTimeout(() => S.style.opacity = 0, dur);
}
function glitchOn(dur) {
  document.body.classList.add('glitch');
  setTimeout(() => document.body.classList.remove('glitch'), dur);
}
.glitch { animation: glitch .3s steps(2) infinite; }
@keyframes glitch {
  0% { transform: translate(0); }
  25% { transform: translate(-1px, 1px); }
  50% { transform: translate(1px, -1px); }
  75% { transform: translate(-1px, -1px); }
  100% { transform: translate(1px, 1px); }
}

The 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.

These 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.

The 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

— which is all of them.

If 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.

Play both games (and 7 others) in the August Sale — 45% off, ends August 31:

https://aguier.itch.io/sale-hub

All games include AI Use Disclosure: AI Assisted labels. Built with AI-assisted development tools.

── more in #developer-tools 4 stories · sorted by recency
── more on @echo chamber 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/writing-mystery-game…] indexed:0 read:8min 2026-08-04 ·