Storied - Project Context#
An AI-powered text adventure RPG with persistent worldbuilding.
Philosophy#
Storied embraces AI "hallucinations" as a feature - the LLM's creative generation weaves compelling narratives with rich worldbuilding. The world starts as a blank slate and resolves to specifics as players explore.
Architecture#
TUI Interface → DM Engine → World State (Markdown) + Rules Engine (5e SRD)
- DM Engine: Agentic LLM loop using Anthropic API (Claude)
- World State: Markdown files with YAML frontmatter in
worlds/ - Rules: 5e SRD (see https://media.dndbeyond.com/compendium-images/srd/5.2/SRD_CC_v5.2.1.pdf)
- Interface: Textual TUI (web interface later)
Key Design Decisions#
- Single-player first, architected for future multiplayer
- Adaptive mechanics - AI decides when dice matter, rolls shown contextually
- Hybrid context retrieval - graph-based for structure, semantic search for lore
- Sandboxed tool execution - AI can generate utilities that become world content
File Structure#
The repo holds code, prompts, and the shipped 5e SRD. Campaign data
(worlds, players, vector indices) lives outside the repo at
~/.storied/ so backups and editing are simple and git status stays
clean.
storied/ # repo
├── design/ # Architecture and design docs
├── plans/ # Implementation plans (in-progress work)
├── prompts/ # All LLM prompts (system prompts, formatting)
├── src/storied/ # Python package
│ ├── engine.py # DM engine core
│ ├── paths.py # Filesystem path getters and configure()
│ ├── claude.py # Claude subprocess driver
│ ├── planner.py # World seeding, planning, ticking
│ ├── cli.py # CLI entry point
│ ├── log.py # Campaign log and timekeeping
│ └── tools/ # MCP tool implementations (per-module)
├── rules/ # Shipped 5e SRD (`make srd`)
├── tests/ # Test suite
└── pyproject.toml
~/.storied/ # data home (configurable; see below)
├── worlds/{world_id}/ # Campaign-specific content + vector index
├── players/{player_id}/ # Character sheets, notes, sessions
└── rules/ # Optional user-level homebrew overlay
└── {content_type}/ # e.g. monsters/goblin.md
The data home defaults to ~/.storied/. Override with the
--base-path flag on storied play / storied reset, or set the
STORIED_HOME environment variable. The CLI calls
storied.paths.configure(...) once at startup; everything downstream
reads from module getters (worlds_path(), player_path(id), etc.).
Prompts#
All LLM prompts live in prompts/ as markdown files, loaded at runtime via load_prompt(name). Don't put prompt text inline in Python code — if it's instructions for a model, it goes in prompts/. Tool docstrings in the tools/*.py modules are the exception since they're tightly coupled to the function signatures and JSON schemas.
Cold-Start Flow#
When storied play runs against a fresh world (no character or no style.md), the CLI enters a single-invocation onboarding sequence:
- Onboarding session —
prompts/new-game.mdruns against the DM engine. The DM weaves character creation with worldbuilding preferences, callingtune(writesstyle.md) andcreate_characteralong the way. The DM must not callset_sceneorestablishduring onboarding. When both artifacts are captured, the DM callsend_session. - Artifact re-check — if either
character.yamlorstyle.mdis still missing after the onboarding session exits, the CLI prints a friendly "onboarding incomplete" message and exits. Re-runningstoried playdrops back into onboarding to finish the gap. - World seeding —
seed_worldruns synchronously (with a progress spinner). It readsstyle.mdand prepends it as a## Player Preferencesblock to the seeder's user message so the bootstrap world reflects the tone/genre the player asked for. - Normal play — a fresh
DMEnginestarts withprompts/dm-system.md, background ticker and advancement evaluator come online, and the player drops into the opening scene.
All four phases happen in one storied play invocation. Sandbox mode (--sandbox) skips onboarding and seeding entirely — it starts with the stock DM system prompt every time.
Content Layers#
Game content is organized in three layers that overlay each other,
with priority world > user > shipped (first match wins). See
design/content-layers.md for the spec.
~/.storied/worlds/{world_id}/ → Campaign-specific (overrides everything)
~/.storied/rules/ → Personal homebrew (overrides shipped)
<repo>/rules/srd-5.2.1/sections/ → Stock 5e SRD (built via `make srd`)
- World layer (flat): per-campaign overrides AND narrative content
(NPCs, locations, factions, threads, lore, maps). A campaign-specific
goblin lives at
~/.storied/worlds/my-game/monsters/goblin.md. - User layer (flat): your personal homebrew that applies across
every campaign. Drop a homebrew spell at
~/.storied/rules/spells/my-spell.mdand it shows up inrecallfor every world. - Shipped layer (nested): the stock 5e SRD bundled with the repo.
The vector index (per-world search.db) tags rows by source —
srd, user, or world. recall(scope="rules") covers all three
sources; recall(scope="world") covers only world-tagged hits.
After adding files to ~/.storied/rules/ for the first time, run
storied index rebuild -w {world} to pick them up. The index is
populated lazily on first use, so existing worlds need an explicit
rebuild to see new homebrew content.
World File Format#
Markdown with YAML frontmatter:
---
type: location
name: The Rusty Anchor
connections: [harbor_main, fish_market]
tags: [tavern, social]
---
# The Rusty Anchor
Description here...
See design/content-layers.md for full specification.
Player State#
Player data lives in ~/.storied/players/{player_id}/:
~/.storied/players/{player_id}/
├── character.yaml # Stats, class, abilities
├── character.md # Free-text backstory
├── notes.md # Appending journal of player observations
├── session.md # Current situation, location, present, threads
└── worlds/{world_id}/ # Player's discovered knowledge per world
Separate from world state to support multiple characters and future multiplayer.
Development#
-
Python 3.12+, full type hints
-
TDD: write tests first
-
Target 100% test coverage with two documented exclusions:
src/storied/cli.py— thin argparse + dispatch only; the real work belongs in domain modules that are tested directly (the CLI is currently still doing too much; thinning is in flight)src/storied/srd/*— one-shot PDF pipeline run viamake srd
The pytest gate (
--cov-fail-underinpyproject.toml) is set to the current floor on the included code, not the target. Raise it as coverage improves; never lower the floor without a specific reason that's worth writing down. -
Use
uv syncto install dependencies
Run commands directly - the .envrc activates the venv, so use storied not .venv/bin/storied or uv run storied.