◆The Dual Mind
Every turn is resolved by two minds that never write to the same place. One is an artist, the other an accountant — and they only meet through a contract of square brackets and JSON.
✦ The Dungeon Master
A free, creative LLM storyteller. Its identity, craft rules and world-consistency laws live in DM_CORE_PROMPT, versioned as v27 — bumping the version forces a cache miss so the model can never serve a stale persona.
It is told: "The story is NOT pre-written: it is shaped by the player's choices, in the moment." It chooses theme, tone and atmosphere. It may say no. Death is real.
✦ The Guardian
A second LLM pass that reads the DM's narration and extracts every mechanical effect into a strict 38-field JSON contract: damage, healing, XP, items, currency, quests, NPC changes, combat, rest, world lore.
It works in two phases: PRE (before the DM writes — decides if a roll is needed) and POST (after — turns prose into state). It never writes a sentence; the DM never writes a number.
✦ Four knobs, one clamp
Each campaign carries four model choices, saved in meta and swappable from the in-game settings: the DM model (storyteller), the Guardian model (roll judge), the Extraction model (POST parser) and the TTS provider (StepFun "Step Plan" or Qwen "Token Plan").
Non-admin players are clamped to a whitelist server-side — the picker in onboarding is only a UX affordance; _clamp_player_model is the real gate.
◆The Turn Pipeline
One player message triggers eight serialized stages. The DM stream is the only thing the player watches; everything after it happens in the dark.
1 Action in
The player's message arrives via SSE. A per-campaign asyncio lock is taken — every state write is serialized, no two turns may race.
2 Guardian PRE
guardian_check_roll reads the action + compact state and decides if the outcome is uncertain. If so it hands the DM the exact [KAST: 1d20+MOD | LABEL (DC X)] tag to use.
3 DM writes
_build_system_prompt assembles the layered prompt (see §3). The DM streams tokens — reasoning shown as a thought bubble, then narration — into the chat.
4 Validate & strip
validate_dm_response checks the shape; mechanical tags are stripped from display text. [KAST:] is parsed into a real dice button the player must press.
5 Guardian POST
In the background, guardian_extract_mechanics sends the reply + last 6 transcript entries + compact state to the extraction model. Invalid JSON triggers a 2-attempt repair loop; total failure returns an empty dict — the turn survives.
6 Apply & save
apply_mechanics mutates state with dedup (skip_effects) against anything the legacy tag pipeline already applied. state.json is written, last_effects recorded — and the loop feeds §1.
◆Prompt Injection
The DM never sees raw JSON. Every turn the engine welds a stack of layers into one system prompt — language first, truth forever, memory in tiers. Expand each layer.
The very first line of the system prompt pins the output language and overrides any Swedish internal notes. The game can be played in Swedish while the UI stays English — language is a per-campaign setting, not a code constant.
[LANGUAGE: ENGLISH] You MUST write ALL narration, dialogue, NPC speech, descriptions, and every single word of your response in English. This overrides any Swedish text in the instructions below — those are internal system notes, NOT the output language.
Then the version stamp — [DM-prompt v27] — which forces a provider cache miss on every prompt change, so a deployed update is instantly live.
The personality charter. The key covenants the model is sworn to:
## Identity and tone - You are an engaged, atmospheric storyteller. Adapt the mood to the scene — threatening in combat, warm by the campfire, tense in mysteries. - ALWAYS respond in the language specified in the [LANGUAGE] or [SPRÅK] directive at the top. - ALWAYS end with an opening — the last sentence should invite the player to act. - Do NOT be afraid to say no. Consequences must be felt. Death is real. ## 🖋️ STORYTELLING CRAFT - SHOW, DON'T TELL: "The innkeeper's hands tremble as she fills your tankard" instead of "she is afraid". - ENGAGE THE SENSES: aim for at least two senses per scene. - IMPLICATION IS STRONGER THAN DESCRIPTION: a sound in the dark, the empty chair, the door left ajar. - EVERY SCENE IS A PROMISE: spark curiosity — a mystery, a threat, an opportunity. ## 🗺️ WORLD CONSISTENCY (CRITICAL) - The world is a FICTIONAL fantasy world. NEVER use real place names. - Every NPC gets a UNIQUE, UNEXPECTED name — never reuse a name style from an earlier NPC. - Keep the world consistent: the same place has the same name, the same NPC has the same personality.
Mechanics are explicitly delegated: "A separate system (Guardian) automatically extracts mechanical effects from your narration… You do NOT need mechanical tags — just write what happens." The exception that stays alive: the [KAST:] roll tag.
The player must never win an item, spell or resource by simply claiming it. The DM is sworn to contradict them, kindly but firmly, offering an alternative path:
## ⚠️ ANTI-HALLUCINATION (CRITICAL) - If the player says "I take my lamp" but the lamp is NOT in the inventory → SAY NO: "You have no lamp. Your hands search the dark but find only cold stone." - If the player says "I cast my spell" but it is not on the character sheet → SAY NO. - You NEVER accept player-invented details that give mechanical advantage. The WORLD and INVENTORY are authoritative. ## ⚖️ THE DM TRIAD — each action is resolved by exactly ONE of three responses 1. SAY YES — creative, reasonable, cinematic (Rule of Cool). 2. SAY NO — breaks the world or the sheet; always offer an alternative. 3. ROLL DICE — outcome uncertain + consequences matter → [KAST:]. ## 🚨 [KAST:] BEFORE OUTCOME — ABSOLUTE RULE (CRITICAL) ❌ WRONG: "You slash at the goblin — the sword hits! 8 damage." ✅ RIGHT: "You slash at the goblin! [KAST: 1d20+5 | ATTACK vs AC 13]" - If you write that the player hits/misses WITHOUT having requested [KAST:] first, it is a SERIOUS ERROR. - The player must ALWAYS roll the die themselves. NEVER narrate the outcome before the tag.
| Difficulty | DC |
|---|---|
| Easy | 8–10 |
| Medium | 12–14 |
| Hard | 16–18 |
| Very hard | 20–22 |
| Nearly impossible | 25+ |
Mechanical advantages (Bardic Inspiration, Bless, Guidance…) must be mentioned with their die in the narration — "A warm melody fills you — you gain Bardic Inspiration (1d6)" — so the Guardian can create the dice button. Active resources must be reminded when appropriate.
Every turn the engine scans state.npcs for living enemies (relation == "fiende" and alive). Enemies present → DM_COMBAT_PROMPT (chat-first combat law, turn order, death saves); otherwise → DM_NARRATIVE_PROMPT. Combat rules only weigh on the context when combat is real.
Combat is chat-first: no battle arena. The DM opens it with the [STRID:name|HP|AC, name2|HP|AC] tag; the Guardian tracks damage, rounds and turn order from narration. Death saves follow D&D 5e exactly: nat 1 = two failures, nat 20 = wake at 1 HP, three of three decides.
NPCs are tagged on introduction — [NPC:Morvaine|Enigmatic wizard|unknown] — and a player can write @Name to make that NPC answer in their own voice, with the DM allowed only brief interjections.
truth_block(state) compacts the whole campaign state into a readable digest (HP, inventory, currency, known NPCs, quests, location, pinned facts) and ships it under a threat:
## SANNING (auktoritär — motsäg ALDRIG detta) / TRUTH (authoritative — never contradict this) HP 21/26 · AC 15 · gold 15gp 4sp · longsword (equipped) · 2 quests active Pinmade fakta: "The Gray Gate has been sealed since the Sundering."
Pinned facts are player-controlled truth — the only part of the world the model is forbidden to forget or revise.
Raw transcripts would drown the context, so memory is distilled into tiers and injected newest-first: the two most recent scene summaries, the two most recent chapter summaries, and the single campaign arc summary. Each is tagged with its turn or chapter number so the DM can reason about chronology.
Then the engine reports what just happened mechanically, from meta.last_effects — "Skada: 12 · Föremål: Torch · Tid: 2h" — so the DM can react to consequences it already caused. Finally: world (location, time, weather), known locations with travel times, lore, NPC list, quests and the character sheet.
Travel time is a formula the DM is handed verbatim: distance ÷ 10 × terrain modifier — road 0.5, plains 0.6, forest 1.2, mountains 1.8, swamp 1.5, minimum half a day per journey.
After the prompt is assembled, the engine asks the fact register which stored facts are relevant to this turn's message and appends the winners as a small authoritative block the DM may never contradict — joined by the four best semantic chunks from the Qdrant index. Full mechanics in §4 — Fact Memory.
## FACT REGISTER (authoritative — never contradict) - [NPC] Kaelen Voss bears the party no ill will (turn 12) - [PLATS] The Hollow Forge hides a second entrance behind the smithy (turn 8)
✦ The assembly order
◆Fact Memory
Facts are extracted after every turn, ranked before every DM reply — and only the top eight make it into the prompt. Nothing is deleted; everything competes.
✦ The register
The extraction model reads every Guardian POST and stores durable facts in a per-campaign FactRegister (facts.json): people, places, promises, grudges, discoveries. Each entry carries its category, source turn, mentions, confidence, and a relevance score between 0.2 and 1.0.
Facts never collide blindly: a new fact that replaces an old one sets superseded_by on its predecessor — replaced facts are excluded from the active set forever.
✦ The ranking
Before each DM turn, every active fact is scored against the player's message:
score = (keyword-overlap + recency + mentions) × relevance-weight × confidence-weight
Recency adds up to +30% for newer facts · mentions add up to +25% for facts the story keeps returning to · a compacted fact's relevance cuts its score by up to −50%. The top 8 are injected as an authoritative block, joined by 4 semantic chunks from the Qdrant index (transcript · lore · summaries).
✦ When facts fall out
Superseded — replaced by a newer, truer fact; never injected again.
Not hot enough — a turn with no keyword overlap and few mentions filters the fact out for that turn (the moving window).
Recency decay — as the campaign grows, old turns score less; old facts sink below the top-8 cut.
Compaction (every 50 turns) — an archivist LLM reads up to 80 active facts and marks the stale ones; their relevance drops to 0.2 — a permanent −50% unless the story re-mentions them hard.
Pinned facts are the exception: player-pinned truth (the /pin command) is injected uncapped in every TRUTH block and never falls out.
✦ Nothing is deleted
The register keeps every fact forever — eviction is only ever ranking. A fact that loses the window today can win it back tomorrow if the story returns to it; that is what makes a 200-turn campaign remember turn 3 when it matters.
◆The Guardian Contract
After the DM speaks, the Guardian must answer with exactly one thing: a JSON object with all 38 fields present. Never an explanation, never a missing key. Watch the extraction happen.
✦ The reading
The Guardian receives the DM reply, the player's action, the last 6 transcript entries and a compact state snapshot. It is a paranoid reader: implicit damage ("the cold bites", "your breath catches") must be caught; a potion drunk is a roll grant (2d4+2), never a fixed number; a "you see a book" is not an item — only "you take the book" is.
If the reply contains invalid JSON, the engine appends a correction — "Returnera ENDAST JSON-objektet, inget annat." — and retries once. Two failures return the empty contract and the turn continues untouched.
✦ The rules it obeys
XP: only kills, quests, milestones, complex puzzles — never walking, resting, trading or small talk. Most turns pay zero XP. Quests pay their stored reward automatically, never double-paid.
Items: every item needs D&D stats, a weight, and a lore line from the campaign's own world. The player never equips themselves — the DM decides what is worn.
NPCs: updates are aggressive — implicit relation shifts (helping → allied, threatening → enemy), name reveals, secrets. Corrections: retract anything the DM implied that shouldn't have happened.
Enemy attacks: the Guardian names the attacker only — "the CODE rolls the dice and the damage" (d20 + attack bonus vs the player's AC). The LLM is never allowed to decide whether an enemy hits.
damage · healing · death · combat_start · combat_round · combat_end · player_attacks · ally_attacks · ally_damage · enemy_attacks · combat_events · initiative_entries
xp · level_up · spells_add · character_updates
items_add · items_remove · currency (pp/gp/sp/cp)
quests_new · quests_completed · quests_failed
npcs_new · npc_relations · npcs_near · npc_notes · npc_name_reveals
locations_new · current_location · world_lore · time_passed · rest · new_day · day_summary · logbook
roll_grants (Bardic Inspiration → 1d6 button) · corrections (retract & explain)
One field is famously dead: status_apply still rides in the empty contract but is never consumed — a relic of an unwired status engine. enemy_actions, by contrast, was resurrected by the Battle AI: when enemies are alive, the Guardian decides their moves and the code rolls the dice — no enemy ever hits or misses by LLM fiat. The audit that found the dead field also found (and fixed) the shape-conflict landmine where a day-entry dict could crash the logbook append inside a swallowed background task, silently eating a turn's effects.
◆The Dice Engine
The single most sacred rule: the LLM never rolls. The DM requests, the Guardian judges, the player rolls — and the result is the only number that ever enters state. Try it below; this roller runs the same notation parser as the backend.
✦ Fair in both directions
Both directions are rolled by code, never by the model. When you roll, the DM's [KAST:] request spawns a real dice button and /api/dice rolls it. When an enemy attacks, the Guardian asks the engine to roll — and the code rolls. The LLM never produces a number, and it never decides whether an enemy hits.
The algorithm is server-side Python's secrets module — cryptographically secure randomness (secrets.randbelow(sides) + 1), identical for player and enemy dice. Notation like 1d20+3 is parsed and validated — 1–100 dice, 2–1000 sides — before anything is rolled.
Because the model never sees the outcome before it is committed, the tale can lie — but the dice cannot.
✦ The two-phase flow
1 · Guardian PRE reads the action and decides the outcome is uncertain. 2 · The DM writes the [KAST: 1d20+MOD | LABEL (DC X)] tag — the only syntax that can spawn a die. 3 · The frontend renders a dice button; the click sends the result back as [Resultat: …]. 4 · The DM must narrate the outcome immediately — never "what do you do?" before resolving.
Advantage/disadvantage is declared in the tag itself (ADVANTAGE/FÖRDEL): two d20, keep best/worst.
✦ Edge rules encoded
Nat 20 = critical triumph · nat 1 = catastrophe. Death saves: 3 successes vs 3 failures; nat 1 counts as two failures, nat 20 wakes the character at 1 HP. Healing potions are always rolled (2d4+2) by the player — the DM may never narrate a fixed heal. Concentration: hit while concentrating → CON save DC 10.
Spell-slot dice and buffs (Bless, Guidance, Heroism) arrive through roll_grants — Guardian reads the narration and creates the button automatically.
◆Voice & the TTS Cache
Every DM line can be spoken aloud — but synthesis is billed per character. So the engine never speaks the same line twice: a small in-memory cache with a ten-minute memory and a hard cap of 64 voices.
✦ How a line becomes voice
Long DM messages (2000+ chars) are split at sentence boundaries into segments of at most 900 characters — Qwen's synthesis chokes on long texts, so each segment is synthesized separately and the MP3 frames are stitched back into one file. Truncation at 1000 chars falls back to the last sentence boundary, never mid-word.
Providers: StepFun (Step Plan) and Qwen (Token Plan), both rendered ~10% faster (1.1× speed) so narration never drags.
✦ The cache contract
Key = provider + voice + normalized text hash. TTL = 600 seconds — a stale entry is a miss and is evicted. Capacity = 64 entries; at the cap the oldest (insertion order) is evicted. Usage is still booked on every request — chars, estimated tokens (~4 chars/token), and true MP3 duration computed by parsing the audio frames (no ffmpeg needed) into each user's ledger.
Try the simulation below: request the same line twice and watch the second one hit.
◆Character Weaving
An adventurer is not generated — it is woven, live, on an SSE stream, from a free-text dream into a finished D&D sheet with inventory, story and stats, ready to preview, save or unravel and re-roll.
✦ The weave
The most creative step in any adventure is the adventurer — the human at the table. The engine's job is to be a worthy partner: every DM turn, every character, every face is generated by FULL-SIZE models at maximum token budget and high reasoning — Qwen 3.8 Max with thinking always on (1M context), DeepSeek V4 at high reasoning, StepFun 3.7 Flash at high reasoning with a full 32k budget. No quantized small models, no context-window truncation, no artificial rate limits on reasoning, no token-capped thinking. The price is latency — a few extra seconds while the DM thinks before it speaks. That wait is the product: a world that reasons, not one that guesses.
The request is a single line — "Skapa en karaktär: {prompt}" — against CHARACTER_PROMPT at temperature 0.95 (the most creative setting in the whole engine) with a maximum token budget and high reasoning — the same full-mind policy as the Dungeon Master, because the adventurer is the most creative step of all. The stream pushes two kinds of frames: reasoning (the invisible thoughts) and content (the growing character JSON), then a done frame with tokens used, seconds elapsed and reasoning length.
The raw text is passed through _extract_json, then _finalize_character_data fills hit dice, ability modifiers, starting equipment and inventory. Token usage is booked to a lifetime ledger that survives even character deletion.
✦ The vault
Saved adventurers live in a vault — one per account, not per campaign — so an adventurer survives a campaign's end. Each entry keeps the full sheet, inventory, story and painted avatar. Summoning into a campaign copies the adventurer into that campaign's state; rerolling spawns a fresh stream.
Avatars are painted separately by an image model — either from the player's own words or from an auto-built sheet prompt — and the status line always tells you which source won.
◆The State Model
One JSON file per campaign is the single source of truth. The DM reads a compact digest of it every turn; the Guardian mutates it every turn; everything else — characters page, NPC dossiers, map, logbook — is just a projection of it.
meta— identity & ledger
character— the sheet
npcs[]— dossier
world— the living world
combat— chat-first battle state
✦ Concurrency & durability
Every read-modify-write runs under a per-campaign asyncio lock — two turns can never interleave. Writes are journaled as JSONL transcripts (one file per session) plus periodic chapter and scene summaries, so the hierarchy in §3 can be rebuilt even after a crash.
The audit that shaped this page found the classic trap: a background writer appending a list to a field another path had written as a dict — an AttributeError inside a swallowed try/except that silently erased a turn's mechanics. The logbook shape is now guarded on both sides.
✦ One XP table, two consumers
D&D 5e level thresholds live in exactly one place — _XP_THRESHOLDS — and both the legacy tag pipeline and the Guardian's level-up check import it. Guardian pays XP only for kills, quests, milestones and puzzles; quest completion pays its stored reward automatically.
✦ The invariant
Everything the player sees — the sheet, the dossier, the map, the logbook, the next DM line — is derived from state.json. The DM may never invent what it holds; the Guardian may never forget to update it. That asymmetry is the whole game.