✦ New in v0.11.0 ·
Corrupted text — {corrupt}…{/corrupt} spans with scramble-settle animation ·
Full changelog →
|
Every choice leaves a mark. Every ending remembers.
A Python CLI text adventure engine with browser play mode.
Fully data-driven — stories are .json files, no code required.
| 🌐 Browser play mode GitHub Pages, no install needed |
🎭 Rich flag system Booleans, ints, strings, deltas |
| ✍️ Conditional inline text Branch inside prose, no new node |
🤖 GPT authoring tool ChoicesMatterGPT writes the JSON |
🔤 Variable text substitution{player_name} in any text field |
👤 Protagonist name prompt Per-story name input, {player_name} token |
💀 Corrupted text{corrupt}…{/corrupt} spans, scramble-settle animation |
⚡ Typewriter mode Character streaming, punctuation pauses, inline {pause} |
|
Getting started Requirements Running Adding a Story Validating a Story Project Structure Contributing |
Writing stories Named Styles Typewriter Mode |
- Python 3.12+
rich(terminal rendering)pytest(tests only)
pip install -r requirements.txtpython main.pyThe engine discovers all .json files in /stories/, presents a numbered picker with node count, endings found, and estimated read time, and runs whichever story you select. Stories with an active save show a ● RESUME badge. Progress autosaves after every choice. Saves are per-story and deleted automatically when you reach an ending.
At the picker prompt:
- Number — select a story
- Q — quit
- C — clear all save data and ending progress (with confirmation)
- T — toggle typewriter mode on/off for the session
- S — open settings (typewriter speed and punctuation pauses; writes to
settings.json)
Tip
ChoicesMatterGPT is a custom GPT trained on this format — describe your story and it generates valid JSON.
Stories are JSON files with two top-level keys: meta and nodes.
{
"meta": {
"id": "your_story_id",
"title": "Display Title",
"version": "1.0",
"author": "Your Name",
"start_node": "intro",
"est_time": "15–25 min"
}
}id is used as the save file key. start_node must match a key in nodes. est_time is optional — if omitted, the engine auto-computes it from word count. warnings is optional — a list of strings shown in a yellow warning panel before launch; affected stories are marked [!] in the picker. auto_visited_flags defaults to true — see the Flags section below.
name_prompt is optional — a non-empty string that triggers a name input screen after content warnings and before the first node. The entered name is stored as player_name and available as {player_name} in any text field. name_default is optional — a per-story fallback used when the player submits empty input; requires name_prompt to also be set.
Each node is a keyed object:
"intro": {
"text": "You wake up in a dim room.",
"choices": [
{ "label": "Try the door", "next": "try_door" },
{ "label": "Search the desk", "next": "search_desk" }
]
}Ending nodes — use is_ending: true and set ending_type to good, bad, or neutral:
"escaped": {
"text": "You slip out into the night. You made it.",
"choices": [],
"is_ending": true,
"ending_type": "good"
}An empty choices array is always treated as an ending, even without is_ending: true.
Choices support requires and sets to gate and track state. State values can be booleans, integers, or strings.
sets — applies values to the player's state when this choice is taken:
{ "sets": { "has_key": true } } // boolean flag
{ "sets": { "trust": 3 } } // absolute integer
{ "sets": { "trust": "+1" } } // delta — adds 1 (unset key defaults to 0)
{ "sets": { "trust": "-2" } } // delta — subtracts 2
{ "sets": { "faction": "red" } } // string assignmentrequires — hides the choice entirely if conditions are not met:
{ "requires": { "has_key": true } } // boolean exact match
{ "requires": { "trust": 3 } } // integer threshold (trust ≥ 3)
{ "requires": { "faction": "red" } } // string exact match
{ "requires": { "faction": ["red", "blue"] } } // string membership (any of)Multiple conditions in one requires dict are ANDed — all must pass for the choice to appear.
State accumulates across the run and is persisted in the save file.
Auto-visited flags: The engine automatically sets visited_<node_id>: true each time a node is entered. Use these in requires to create revisit-aware content with no sets boilerplate:
{
"label": "You remember this room. Check the panel again.",
"next": "panel_check",
"requires": { "visited_generator_room": true }
}Warning
The visited_ prefix is reserved — setting it via choice.sets raises a validation error. Set "auto_visited_flags": false in meta to opt out of automatic tracking.
Choices with "obfuscated": true render as [REDACTED ██████] in the choice list. The player can still select the option — they just don't know what it is until after. Use it for irreversible decisions or choices the character makes without fully understanding what they're doing:
{ "label": "Pull the lever", "next": "consequences", "obfuscated": true }Two-level color system for choice number prefixes. Set "choice_number_color" on a node as a fallback for all its choices, then override per-choice with "color":
"platform_7": {
"choice_number_color": "bright_red",
"choices": [
{ "label": "Run.", "next": "escape", "color": "green" },
{ "label": "Stay.", "next": "caught" }
]
}Use color to signal emotional register, not decoration. "bright_red" for danger, "green" for safety, "yellow" for uncertainty. Both accept any rich color name or hex (e.g. "#ffaa00").
Nodes can have insets — styled lines rendered inside the story panel, above or below the main text, separated by a dim rule. Use them for timestamps, log entries, documents, or any in-world text that belongs inside the scene rather than around it:
"intro": {
"insets": [
{ "text": "Platform 3 — 23:58", "position": "before", "style": "system" }
],
"text": "You've missed the last train...",
"choices": [...]
}| Field | Required | Notes |
|---|---|---|
text |
Yes | Line of text shown inside the panel |
position |
No | "before" (default) or "after" the main text |
style |
No | Named style key — see below; "" renders as dim italic |
requires |
No | Same flag dict as choices — hides inset if not matched |
Nodes can have overlays — flavour lines that appear conditionally before or after the choice list, based on what the player knows:
"use_key": {
"text": "The key fits. The lock turns.",
"overlays": [
{
"text": "Harrow's words surface: 'The guard has a weakness for silver.'",
"requires": { "logbook_read": true },
"position": "before",
"style": "echo"
}
],
"choices": [...]
}| Field | Required | Notes |
|---|---|---|
text |
Yes | Whispered line of text |
position |
No | "before" (above choices) or "after" (below choices, default) |
style |
No | Named style key — see below; "" uses the default overlay style |
requires |
No | Same flag system as choices; omit to show unconditionally |
Multiple overlays can stack; before and after accumulate independently. On ending nodes, all overlays appear before the ending panel.
Write conditional spans directly inside any text field — node text, inset text, or overlay text — without branching to a separate node:
{flag?shown when true|shown when false}
The false branch is optional; omitting it collapses to nothing when the flag is unset:
"lobby": {
"text": "The receptionist {is_staff?gives you a professional nod.|watches you carefully.}",
"insets": [
{ "text": "{is_staff?STAFF ACCESS GRANTED}", "position": "before", "style": "system" }
],
"choices": [...]
}Truthiness mirrors the flag system: true, integer ≥ 1, and non-empty strings resolve to the true branch; false, 0, "", and missing flags resolve to the false branch. Flag names must match \w+ (letters, digits, underscores).
Write {key} inside any text field — node text, insets, or overlays — and the engine replaces it at runtime with the current value of that flag:
"text": "Welcome back, {player_name}. You have {coins} gold coins."- Missing keys leave the placeholder intact (
{unknown_flag}stays as-is) - Substitution runs before conditional inline resolution, so substituted values can appear inside
{flag?...}branches:
"text": "{known?Hello, {player_name}!|Hello, stranger!}"Warning
{pause} is reserved by the typewriter system — don't use it as a flag name. player_name is reserved for the protagonist name feature.
Add name_prompt to meta to ask the player for a name before the first node:
{
"meta": {
"name_prompt": "What is your name, Detective?",
"name_default": "The Detective"
}
}name_prompt— text shown on the name input screen (triggers the feature)name_default— fallback used when the player submits empty input and has no saved name (optional; requiresname_prompt)- The entered name is stored as
player_nameand available as{player_name}anywhere via variable substitution - Prompt is skipped on save resume — the saved name is used directly
- Players can set a persistent default name via Settings → Player name (default:
Felix)
Note
Stories that use {player_name} without name_prompt will use the player's saved name. You don't need a prompt to use the token.
Wrap any text in {corrupt}…{/corrupt} to render glitched characters at runtime:
"text": "{corrupt:0.8:random}THE SIGNAL IS NOT —— ARTEFACT{/corrupt} — it responded."Both params are optional. intensity is a float 0–1 controlling how many characters are replaced. mode is consistent (same characters every render — stable glitch) or random (different each time).
Set a node-level baseline via node.corruption to corrupt all text in that node without repeating the span on every field:
"corrupted_room": {
"corruption": { "intensity": 0.4, "mode": "consistent" },
"text": "The label is barely readable. {corrupt:0.9:random}DANGER{/corrupt}"
}Inline span params override the node baseline. The node baseline itself is multiplied against the global corruption.intensity from settings.json.
Configure globally:
{
"corruption": {
"enabled": true,
"intensity": 0.5,
"mode": "consistent",
"charset": "blocks",
"animate": true,
"scramble_frames": 6,
"scramble_delay_ms": 40
}
}charset |
Characters used |
|---|---|
blocks (default) |
█ ▓ ▒ ░ |
symbols |
░ ╬ ▐ ╫ and similar box-drawing |
diacritics |
Combining Unicode diacritic marks |
| (custom) | Any characters via "custom_chars": "…" |
When animate: true and typewriter mode is on, corrupted spans run a scramble-then-settle animation — the text appears maximally glitched, then resolves character by character into its settled form.
Note
Accessible mode strips all corruption — reader mode always shows plain text regardless of story or settings configuration.
Both overlays and insets accept a style field. The built-in named styles are:
| Name | Color | Look | Prefix | Use for |
|---|---|---|---|---|
whisper |
cyan | dim italic | ✦ |
Quiet asides, intimate atmosphere |
echo |
blue | dim italic | ~ |
Distant voices, remembered words, intrusive thoughts |
warning |
yellow | bold | ⚠ |
Danger signals, urgent realisations |
memory |
magenta | dim italic | ◈ |
Flashbacks, implanted memories, recollections |
system |
white | dim | (none) | Timestamps, logs, documents, clinical text |
Copy settings.example.json to settings.json (gitignored, per-user). Override any built-in style or add your own:
{
"styles": {
"warning": { "color": "red" },
"classified": { "color": "green", "dim": true, "italic": false, "bold": false, "underline": false, "strike": false, "prefix": "[REDACTED] " }
},
"overlay": {
"color": "cyan",
"dim": true,
"italic": true,
"prefix": "✦ "
}
}overlay sets the default style for overlays with no style key. Missing or malformed settings.json silently falls back to built-in defaults.
Note
settings.json is gitignored — it's per-user and never committed. Copy settings.example.json to get started.
Enable character-by-character text streaming in settings.json:
{
"typewriter": {
"enabled": true,
"delay_ms": 35,
"punctuation_pauses": {
".": 550,
"!": 250,
"?": 350,
"…": 700,
"—": 600
}
}
}delay_ms— base delay per character in millisecondspunctuation_pauses— extra pause (ms) after specific characters; set any to0to remove it- Press any key mid-animation to skip to the full text
- After prose finishes, choices stagger in at 60ms each after a short breath
- Toggle on/off at the story picker with T without editing
settings.json
Inline pause token — embed {pause} anywhere in node or ending text to inject a deliberate mid-stream delay:
"text": "You reach for the handle.{pause}The door swings open."The pause duration is typewriter.pause_ms (default 500 ms). In non-typewriter mode the token is stripped silently.
Drop any .json file into /stories/. No code changes needed.
Note
Malformed stories show as -ERROR in the picker and can be selected to display the validation message — the engine never crashes at startup.
Stories can be organised into subfolders inside /stories/. The picker shows each subfolder as a named folder entry with a story count; selecting it drills into a sub-screen. Root stories always appear alongside folders.
python scripts/validate_story.py stories/your_story.jsonChecks schema (via the engine's own loader), reachability (BFS from start_node), and dead-ends (reachable nodes with empty choices and no is_ending). Accepts multiple files:
python scripts/validate_story.py stories/horror/*.json stories/sci-fi/*.jsonOutput: WARN for unreachable nodes, ERROR for dead-ends and schema failures. Exit codes: 0 = clean, 1 = errors found, 2 = no arguments.
main.py Entry point — story picker, wires components
src/engine.py Game loop, navigation, save triggers, ending detection
src/story.py Data models (Story, Node, Choice, Overlay, Inset), loader, validation
src/save.py Persistent save state — read/write/delete per story
src/gallery.py Ending gallery — tracks found endings across runs
src/display.py All rich rendering — nothing else imports rich
src/config.py Loads settings.json, merges with defaults
scripts/validate_story.py Story validator — schema, reachability, dead-end detection
scripts/sync_stories.py Regenerates web/stories.json manifest from stories/
index.html GitHub Pages entrypoint for browser play
web/app.js Browser play mode — rendering, screen state, UI logic
web/engine.js Browser engine — pure game logic (no DOM)
web/storage.js Browser storage — save/gallery via localStorage
web/typewriter.js Browser typewriter — animation and settings
web/style.css Browser play mode styling
web/stories.json Static manifest of bundled stories (auto-generated)
accessibility/ WCAG 2.1 AA docs — implementation guide, screen reader test scripts, renderer patterns
/stories Drop .json story files here — auto-discovered at startup (subfolders supported)
/saves Auto-generated — one .save.json + one .gallery.json per story
settings.example.json Committed template (typewriter, overlay, named styles)
pytestSuggestions, bug reports, and code contributions are welcome. See CONTRIBUTING.md for the full guide.
The short version:
- Feature idea → open a GitHub Issue with the
suggestionlabel, or PRSUGGESTIONS.mddirectly using the template at the bottom of that file - Bug → open a GitHub Issue with steps to reproduce
- Code PR → check that the feature is accepted in
SUGGESTIONS.mdfirst, include tests, keep it focused
AI use disclosure
This project was built in close collaboration with Claude (Anthropic). The engine, story format, and tooling were developed using Claude Code as a coding assistant. Most story content was written by Claude under human direction — concept, structure, curation, and all design decisions are the author's own. Commits co-authored by AI include a Co-Authored-By: Claude trailer in the commit message.
