I built a branching-narrative engine in one HTML file (no Twine, no Ren'Py, no build step)

javascript dev.to

A choose-your-own-adventure engine in ~200 lines of vanilla JS

Most tools for branching / choice-based stories are heavier than the story
needs. Twine gives you a whole IDE and a runtime; Ren'Py is a full Python game
engine. Both are great — but if all you want is text with choices that lead to
other text
, and you want the result to be a single file you can host anywhere
static files work (itch.io HTML5 embed, GitHub Pages, a folder on a USB stick),
that's overkill.

So I wrote a tiny one. No dependencies, no build step, no server. The entire
game — styling, engine, and story — lives in one index.html. Open it and it
runs. Here's how it works.

The core idea: your whole story is one object

const passages = {
  Start: {
    text: `
You wake in an unfamiliar room. A door stands ajar to the north;
a window lets in grey light to the east.`,
    choices: [
      { label: "Go through the door", to: "Hallway" },
      { label: "Climb out the window", to: "Garden" },
    ],
  },
  Hallway: {
    text: `The hallway is long and silent.`,
    choices: [ { label: "Keep walking", to: "End_Good" } ],
  },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

Each key is a passage ID (never shown to the player). Each value has text
and a list of choices, and each choice names the passage it leads to. That's
the whole data model.

Rendering a passage

The engine keeps a current pointer and re-renders on every choice click:

function render(id) {
  const p = passages[id];
  const stage = document.getElementById("stage");
  stage.innerHTML = "";

  // paragraphs: split on blank lines, wrap each in <p>
  p.text.trim().split(/\n\s*\n/).forEach(block => {
    const el = document.createElement("p");
    el.textContent = block.trim();
    stage.appendChild(el);
  });

  // choices
  (p.choices || []).forEach(c => {
    if (c.if && !c.if(state)) return;       // stat-gated choice, see below
    const btn = document.createElement("button");
    btn.textContent = c.label;
    btn.onclick = () => { if (c.do) c.do(state); render(c.to); };
    stage.appendChild(btn);
  });
}
Enter fullscreen mode Exit fullscreen mode

Two extras make it feel like a real game without adding a framework:

Stat gating. A choice can carry an if(state) predicate — the button only
renders when it returns true. Give the player a trust score, and a risky
option only appears once they've earned it:

{ label: "Tell her the truth", to: "Confession", if: s => s.trust >= 2 }
Enter fullscreen mode Exit fullscreen mode

Side effects. A choice can carry a do(state) that mutates state before
moving on:

{ label: "Help the stranger", to: "Camp", do: s => { s.trust++; } }
Enter fullscreen mode Exit fullscreen mode

Save / load with zero backend

Because the entire game state is a plain object and a passage ID, saving is one
line to localStorage:

function save() {
  localStorage.setItem("save", JSON.stringify({ current, state }));
}
function load() {
  const raw = localStorage.getItem("save");
  if (!raw) return;
  ({ current, state } = JSON.parse(raw));
  render(current);
}
Enter fullscreen mode Exit fullscreen mode

Endings

Mark terminal passages with ending: true and no choices; the engine shows a
"the end" panel and a "play again" button, and can track which endings a player
has discovered across playthroughs (also stored in localStorage) for a little
completionist hook.

Why one file

  • No toolchain. Nothing to install, nothing to compile, nothing to update.
  • Hosts anywhere. itch.io's HTML5 embed, GitHub Pages, or opened straight from disk — it's just an HTML file.
  • Readable & hackable. The whole thing is short enough to read in one sitting and edit by hand. Re-skin the colors via a handful of CSS variables.
  • Portable stories. Your narrative is a single JS object you can lint, diff, and generate programmatically.

Get the template

I packaged this up — the engine, a documented demo story, and a README — as a
drop-in template so you don't have to rebuild the plumbing:

👉 https://avagrad.itch.io/story-engine-template

If you build something with it I'd genuinely love to see it. The whole point
was to make the "just text and choices" case as low-friction as possible.

Source: dev.to

arrow_back Back to Tutorials