A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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)

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:

  1. Onboarding sessionprompts/new-game.md runs against the DM engine. The DM weaves character creation with worldbuilding preferences, calling tune (writes style.md) and create_character along the way. The DM must not call set_scene or establish during onboarding. When both artifacts are captured, the DM calls end_session.
  2. Artifact re-check — if either character.yaml or style.md is still missing after the onboarding session exits, the CLI prints a friendly "onboarding incomplete" message and exits. Re-running storied play drops back into onboarding to finish the gap.
  3. World seedingseed_world runs synchronously (with a progress spinner). It reads style.md and prepends it as a ## Player Preferences block to the seeder's user message so the bootstrap world reflects the tone/genre the player asked for.
  4. Normal play — a fresh DMEngine starts with prompts/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.md and it shows up in recall for 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 via make srd

    The pytest gate (--cov-fail-under in pyproject.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 sync to install dependencies

Run commands directly - the .envrc activates the venv, so use storied not .venv/bin/storied or uv run storied.