DEV Community

aguier
aguier

Posted on

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

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


The Genre Nobody Talks About in Browser Games

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.

The Architecture: Single File, Zero Dependencies

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)
Enter fullscreen mode Exit fullscreen mode

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.

State Tracking: Suspicion/Evidence vs. Trust/Doubt

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

Echo Chamber: Suspicion and Evidence

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());
  });
}
Enter fullscreen mode Exit fullscreen mode

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

SUSPICION: ███░░  EVIDENCE: 2/3  ROUND: 1/3
Enter fullscreen mode Exit fullscreen mode

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: Trust and Doubt

Flatline mirrors this with a different emotional axis:

let trust = 0, doubt = 0, round = 0;
Enter fullscreen mode Exit fullscreen mode

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.

The Typewriter Effect: 36ms Per Character

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; };
}
Enter fullscreen mode Exit fullscreen mode

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.

Six Endings from Two Variables

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");
  }
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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.

Web Audio: Heartbeats from 15 Lines of Code

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);
}
Enter fullscreen mode Exit fullscreen mode

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

SVG EKG Animation in Flatline

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;
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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

The Glitch and Flicker System

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);
}
Enter fullscreen mode Exit fullscreen mode
.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); }
}
Enter fullscreen mode Exit fullscreen mode

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.

Why This Matters

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

  • Echo Chamber — An Interrogation Mystery — $3 (45% off in August Sale)
  • Flatline — A Medical Thriller — $3 (45% off in August Sale)

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

Top comments (0)