◆The Dual Mind
One artist, one accountant, and a third party neither of them may overrule: the engine.
✦ The Dungeon Master
A free, creative storyteller. Identity and craft laws live in DM_CORE_PROMPT, versioned v28 — a version bump forces a provider cache miss, so a stale persona can never be served.
"The story is NOT pre-written — it is shaped by the player's choices, in the moment." Death is real.
✦ The Guardian
A second LLM pass that reads the narration and extracts every mechanical effect into a strict 44-field JSON contract. It never writes a sentence — and in combat, never a number.
Fallback: a legacy regex pipeline ([SKADA:] [GULD:]…) for out-of-combat effects. Every combat number is engine-rolled.
✦ The Engine
Plain server code. It rolls the dice (Python secrets), decrements pools, ticks conditions, pays XP from one table, and writes state.json under a per-campaign asyncio lock.
The tale may lie — the code cannot.
✦ Four knobs, one clamp
Each campaign carries four model choices in meta: the DM (storyteller), the Guardian (roll judge), the Extraction model (POST parser) and the TTS provider (StepFun or Qwen). Non-admin players are clamped to a whitelist server-side — _clamp_player_model is the real gate, the picker is only UX.
◆The Turn Pipeline
One message, six serialized stages. The DM stream is the only thing you watch — the rest happens in the dark.
◆Prompt Injection
The DM never sees raw JSON. Every turn the engine welds twelve layers into one system prompt — language first, truth forever, memory in tiers. Watch the stack assemble.
✦ The [KAST:] absolute rule
❌ WRONG: "You slash at the goblin — the sword hits! 8 damage." ✅ RIGHT: "You slash at the goblin! [KAST: 1d20+5 | ATTACK vs AC 13]"
The player always rolls. The DM narrates only after the result returns as [Resultat:]. Advantage/disadvantage is declared in the tag itself.
✦ The DC scale
| Difficulty | DC |
|---|---|
| Easy | 8–10 |
| Medium | 12–14 |
| Hard | 16–18 |
| Very hard | 20–22 |
| Nearly impossible | 25+ |
Routine = auto-success. Pressure raises DC, preparation lowers it.
◆Fact Memory
Facts are extracted after every turn and ranked before every reply — only the top eight ship. Nothing is deleted; everything competes.
✦ The ranking
score = (keyword-overlap + recency + mentions) × relevance-weight × confidence-weight
Recency +30% · mentions +25% · compacted relevance −50%. Superseded facts never ship again.
✦ The background rhythm
every 2 turns — fact extraction
every 5 turns — Qdrant re-index
every 10 turns — active threads re-woven
every 50 turns — compaction: an archivist LLM drops stale facts to relevance 0.2
Pinned facts (/pin) never fall out — injected uncapped, forever.
◆The Guardian Contract
After the DM speaks, the Guardian answers with exactly one thing: a JSON object, all 44 fields present, never an explanation.
⚔ Health & combat — 12
🔥 Conditions & resources — 8
⭐ Progression — 3
🪙 Possessions — 3
⚑ Quests — 3
👥 People — 5
🗺 World & time — 8
✎ Grants & fixes — 2
✦ Paranoid reading
It gets the reply, the action, the last 6 transcript entries and a compact state snapshot. Implicit damage ("the cold bites") must be caught; a potion drunk is a roll grant (2d4+2), never a fixed number; "you see a book" is not an item — only "you take the book" is.
Invalid JSON → one repair retry. Two failures → the empty contract. The turn always survives.
✦ Combat never runs on LLM fiat
enemy_attacks names the attacker only. The engine rolls d20 + attack bonus vs your AC — cover, crits, disadvantage from conditions, resistances — then rolls damage from the enemy's damage_dice. Conditions apply real effects and tick each round in code.
XP: only kills, quests, milestones, puzzles — never walking or small talk. Most turns pay zero.
◆The Dice Engine
The most sacred rule: in your fights, the model never decides the numbers. The DM requests rolls, the Guardian judges them, the engine rolls.
✦ Two-phase flow
1 Guardian PRE spots uncertainty · 2 DM writes [KAST: 1d20+MOD | LABEL (DC X)] — the only syntax that spawns a die · 3 your click resolves via POST /api/dice (Python secrets, 1–100 dice, 2–1000 sides) and returns as [Resultat:] · 4 the DM narrates the outcome — never before.
Honest caveat: if /api/dice is unreachable the client rolls locally — and that fallback is marked in the chat. You always know which numbers came from the server.
✦ Edge rules encoded
Nat 20 critical · nat 1 catastrophe. Death saves: 3 vs 3 — nat 1 counts double, nat 20 wakes you at 1 HP. Potions are always rolled (2d4+2). Buffs arrive through roll_grants — Guardian reads the narration and creates the button.
Because the model never sees the outcome before it is committed — the tale can lie, the dice cannot.
◆Spell Slots & Rests
Magic is a resource, and rest is how you get it back. One pool, level-as-cost — every rest rule applied by the engine, never by prose.
✦ Level as cost
Casting spends the spell's level from the pool — a level-3 casting costs 3, cantrips are free. The Guardian extracts spell_slots_spend {name, level}; the code decrements. Pool empty → the cast is blocked, and the chat says so. A dry well is never silent.
✦ Rests
Long rest (8h): HP to max, all slots back, half your hit dice (min 1), exhaustion −1. If eight hours pass in narration, the rest lands even if the DM forgets the tag.
Short rest: spend one hit die — server dice roll it (1d6–1d12 by class) + CON. Exhaustion follows the 5e ladder; only rest moves it.
◆Voice & the TTS Cache
Synthesis is billed per character — so the engine never speaks the same line twice. 64 slots, ten-minute memory.
✦ How a line becomes voice
Long messages split at sentence boundaries into ≤900-char segments → each synthesized (StepFun or Qwen, ~1.1× speed) → MP3 frames stitched into one file. Key = provider + voice + style + text · TTL 600s · cap 64, oldest evicted. Usage (chars, tokens, true MP3 duration) is booked to your ledger on every request — cache hits included, minus the API call.
◆Character Weaving
An adventurer is not generated — it is woven, live, on an SSE stream: a free-text dream in, a finished D&D sheet out.
✦ The weave
One line — "Skapa en karaktär: {prompt}" — against CHARACTER_PROMPT at temperature 0.95, the most creative setting in the engine, with high reasoning on full-size models. The stream pushes reasoning frames (the invisible thoughts), then content frames (the growing JSON), then done with tokens, seconds and reasoning length.
_extract_json → _finalize_character_data fills hit dice, modifiers, starting gear. Tokens book to a lifetime ledger that survives deletion.
✦ The vault
Saved adventurers live in a vault — one per account, not per campaign — so they outlive any adventure. Summoning copies the sheet into a campaign's state; rerolling spawns a fresh stream. Avatars are painted separately, from your words or an auto-built sheet prompt.
◆The State Model
One JSON file per campaign is the single source of truth. Everything you see is a projection of it.
✦ The shape
character — the sheet
world — the living world
combat — chat-first battle state
npcs[] · quests[] · locations[]
✦ Concurrency & one XP table
Every read-modify-write runs under a per-campaign asyncio lock; transcripts journal as JSONL plus scene/chapter summaries, so the hierarchy rebuilds after a crash.
D&D 5e thresholds live in exactly one place — _XP_THRESHOLDS — imported by both the legacy tag pipeline and the Guardian's level-up check.
The invariant: the DM may never invent what state.json holds; the Guardian may never forget to update it. That asymmetry is the whole game.
◆Undoing a Turn
Every turn is snapshotted before it is written. One rewind, one turn, one level deep — press it and watch the world step back.
turn 41 · snapshot
quests: 2 active
facts: 34 · threads: 4
turn 42 · written
logbook +1 · npcs_near:[Kip]
time_passed: 1h
after rewind
✦ What the rewind takes back
Your message, the DM's reply, and everything the Guardian recorded — state mutations, the fact register, transcript lines, scene/chapter summaries. The vector index is purged and re-indexed from the restored transcript.
✦ No zombie data
Late background jobs from the undone turn are disarmed by a bumped epoch: they check it before saving and drop their writes. Single-level only — each snapshot overwrites the previous one, and a restore consumes it.