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" } ],
},
// ...
};
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);
});
}
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 }
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++; } }
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);
}
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.