A live D&D session companion that listens to your table, tracks HP and combat state in real time, generates narrative scene panels, and publishes a living campaign journal to GitHub Pages — all powered by local LLMs.
Campaign journal: doughatcher.github.io/adventure-log
Start screen — shown when no session is in progress. Click to begin or load a past session.
Live session — party tracker, color-coded transcript log (DM / PC / Roll / OOC), AI-updated scene and next-steps panels, and map graph. The decision helper pops up automatically when the AI detects an active fork in play.
Past sessions — browse archived sessions, view scene/story/transcript tabs, download the MP3 recording, or load the session into the main stage for review.
| Feature | How |
|---|---|
| Voice → Transcript | Microphone audio → local Speaches/Whisper → live transcript log |
| Fast AI pass (~4s) | Extracts HP, enemies, conditions from transcript |
| Full AI pass (~20s) | Updates Scene, Story, Map, and Next Steps panels |
| Party tracker | Fuzzy HP tracking; enemies appear when mentioned; long rest restores |
| Decision modal | Pops up when AI detects an active choice (shop, fork, tactic) |
| Session archive | End Session: audio → MP3, all panels → markdown archive |
| Campaign journal | GitHub Action auto-generates narrative HTML and deploys to Pages |
| D&D Beyond polling | Live character HP/conditions synced from DDB character API (see below) |
Microphone → Speaches STT → transcript.md
↓
dnd-stage server (FastAPI)
↓
Ollama (local LLM, e.g. gemma4)
├── fast pass: state.json (HP, conditions)
└── full pass: panels/*.md (scene, story, map)
↓
Session archive (data/sessions/YYYY-MM-DD-HHMM/)
↓
GitHub Action (generate_journal.py → Claude API)
├── content/sessions/*.md (narrative journal post)
├── content/characters/*.md (character pages)
└── context/next-session-brief.md
↓
Hugo → GitHub Pages (adventure-log)
Optional: D&D Beyond → ddb_poll.py → PATCH /api/characters/{slug}
| Service | Purpose | Default |
|---|---|---|
| Ollama | Local LLM for state/panel generation | localhost:11434 |
| Speaches | Whisper STT for voice transcription | localhost:8000 |
ffmpeg |
Audio chunk concat → MP3 | system PATH |
Recommended model: gemma4:e4b — 12GB VRAM, ~50 t/s on RTX 4070.
ollama pull gemma4:e4b# 1. Clone
git clone https://github.com/doughatcher/adventure-log
cd adventure-log
# 2. Configure
cp .env.example .env
# Edit .env — set OLLAMA_MODEL, optionally GITHUB_TOKEN for releases
# 3. Run (requires uv)
uv run uvicorn server.main:app --host 0.0.0.0 --port 3200 --reload
# 4. Open http://localhost:3200docker build -t dnd-stage .
docker run -p 3200:3200 \
-e OLLAMA_BASE=http://host.docker.internal:11434 \
-e SPEACHES_BASE=http://host.docker.internal:8000 \
-v $(pwd)/session:/app/session \
-v $(pwd)/data:/app/data \
dnd-stageLive HP and condition sync from D&D Beyond's character API into the party tracker.
# Step 1: Open browser, log in to DDB, capture CobaltSession cookie
python scripts/ddb_auth.py
# → Writes DDB_COOKIE to .env
# Step 2: Discover campaign character IDs
python scripts/ddb_discover.py
# → Writes DDB_CHARACTER_IDS to .env
# Step 3: Start polling loop
python scripts/ddb_poll.py
# → Polls DDB every 60s, PATCHes http://localhost:3200/api/characters/{slug}ddb_auth.py— opens a Playwright browser (non-headless) for manual login to D&D Beyond, extracts theCobaltSessionHttpOnly cookie using CDP, writes to.envddb_discover.py— exchanges the cookie for a cobalt API token, callsapi.dndbeyond.com/campaign/stt/active-short-characters/{campaign_id}, writes character IDs to.envddb_poll.py— polls each character's stat endpoint onDDB_POLL_INTERVAL(default 60s), maps slugs to DDB character IDs, PATCHes the dnd-stage server with current HP and conditions
- Requires GNOME/desktop session: DDB login uses PerimeterX bot detection that blocks headless Chromium. Run
ddb_auth.pyfrom a terminal with a display. - HttpOnly cookie:
CobaltSessionis not readable viadocument.cookie. The scripts use Playwright CDP (context.cookies()) which can access HttpOnly cookies after an authenticated session. - Token refresh:
ddb_poll.pyrefreshes the cobalt token each poll cycle — tokens expire (~1800s TTL). - Slug mapping: Character slugs (e.g.
rides-the-wake) must match filenames indata/characters/. EditDDB_CHARACTER_IDSin.envto setslug=ddb_idpairs.
After each session, push the archived data/sessions/YYYY-MM-DD-HHMM/ folder to main. A GitHub Action fires:
- Generates narrative — calls Claude API to write journal prose, character updates, and a dense next-session AI brief from the session transcript and panels
- Commits content — writes
content/sessions/andcontent/characters/markdown - Deploys Hugo — builds the static site and pushes to GitHub Pages
The journal accumulates session by session. Each entry builds on the last — characters develop, the story deepens, context grows. context/campaign-history.md and context/next-session-brief.md are fed to Claude on each generation run so the narrative always has full backstory.
Required secrets: ANTHROPIC_API_KEY in repo Settings → Secrets → Actions.
Party HP and conditions can be partially updated without overwriting other fields:
# HP only — preserves class, AC, notes, conditions
curl -X PATCH http://localhost:3200/api/characters/granit \
-H "Content-Type: application/json" \
-d '{"hp_current": 30}'
# Conditions only
curl -X PATCH http://localhost:3200/api/characters/rides-the-wake \
-H "Content-Type: application/json" \
-d '{"conditions": ["Poisoned", "Frightened"]}'All settings via .env:
| Variable | Default | Description |
|---|---|---|
OLLAMA_BASE |
http://localhost:11434 |
Ollama API endpoint |
OLLAMA_MODEL |
gemma4:e4b |
Model for all generation |
SPEACHES_BASE |
http://localhost:8000 |
Speaches STT endpoint |
PORT |
3200 |
HTTP/WS port |
GITHUB_REPO |
— | owner/repo for release publishing |
GITHUB_TOKEN |
— | Token with repo write scope |
DDB_CAMPAIGN_ID |
— | D&D Beyond campaign ID |
DDB_COOKIE |
— | CobaltSession cookie (set by ddb_auth.py) |
DDB_CHARACTER_IDS |
— | slug=id,slug=id pairs (set by ddb_discover.py) |
DDB_POLL_INTERVAL |
60 |
DDB poll interval in seconds |
STATE_TRIGGER_CHARS |
80 |
Transcript chars before fast state update |
STATE_DEBOUNCE_SECS |
6 |
Fast state update debounce |
PANEL_TRIGGER_CHARS |
300 |
Transcript chars before full panel update |
PANEL_DEBOUNCE_SECS |
12 |
Full panel update debounce |
dnd-stage/
├── client/ # Frontend (vanilla JS, no build step)
├── server/ # FastAPI backend
│ ├── main.py # Routes, WebSocket, PATCH endpoint
│ ├── gemma.py # LLM prompting, panel/state updates
│ ├── stt.py # Speaches STT integration
│ └── config.py # Env vars + DDB config
├── scripts/ # D&D Beyond integration
│ ├── ddb_auth.py # Browser login → CobaltSession cookie
│ ├── ddb_discover.py # Campaign character ID discovery
│ └── ddb_poll.py # Live HP/conditions polling loop
├── data/
│ ├── characters/ # Character .md files (stats frontmatter)
│ └── sessions/ # Archived sessions (push to trigger journal)
├── session/ # Live session state (gitignored)
├── content/ # Hugo site content (auto-generated)
│ ├── characters/ # Character narrative pages
│ └── sessions/ # Session journal entries
├── context/ # AI generation context
│ ├── campaign-history.md # World lore, character profiles
│ └── next-session-brief.md # Dense AI brief, updated each session
├── layouts/ # Custom Hugo templates
├── .github/
│ ├── workflows/campaign-journal.yml # Pages deploy pipeline
│ └── scripts/generate_journal.py # Claude journal generator
├── hugo.toml
└── .env.example



