engine_boot · loreweavers-cauldron · v28
CLICK TO SKIP — ENTER TO BEGIN
The Lore Weaver's Cauldron — under the table

The Mechanics of the Cauldron

Two minds, one die cup, one JSON file. Everything else is a projection.

PLAYER ONE MESSAGE = ONE TURN DUNGEON MASTER DM_CORE_PROMPT v28 · THE ARTIST WRITES THE STORY · MAY SAY NO GUARDIAN 44-FIELD CONTRACT · THE ACCOUNTANT PROSE → JSON · NEVER A SENTENCE THE ENGINE — CODE, NOT PROSE SECRETS DICE · ASYNCIO LOCK · STATE.JSON CONDITIONS TICK · XP TABLE · COMBAT ROLLS action narration 44 fields TRUTH block PRE: roll?
DM prompt v28 Guardian · 44 fields Server-rolled dice TTS cache · 64 × 600s SSE character weaving Undo · costs 1 turn
▼ scroll to open the hatch ▼
§1

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.

narratorNPC actorrules arbiterchallenger

"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.

PRE — is a roll needed?POST — prose → state

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.

dicespell slotsconditionstravel timelevel-ups

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.

step-3.7-flashstep-5-previewstep-3.5-flash-2603qwen3.8-flashqwen3.8-maxqwen3.6-flashdeepseek-v4.1-flash
§2

The Turn Pipeline

One message, six serialized stages. The DM stream is the only thing you watch — the rest happens in the dark.

01ACTION IN SSE · per-campaign asynciolock — no two turns race 02GUARDIAN PRE guardian_check_roll —uncertain? hand DM a [KAST:] 03DM WRITES layered prompt (§3) · tokensstream: thought → narration 04VALIDATE & STRIP tags stripped from display ·[KAST:] → dice button 05GUARDIAN POST reply + last 6 entries → JSON2-attempt repair · never fatal 06APPLY & SAVE apply_mechanics + dedup →state.json · last_effects loop feeds turn N+1
the turn packet serialized path state loop-back
transcript.snapshot · turn 42
❯ you: I edge toward the door, dagger ready, and try to slip past the guard. [sneak]
# guardian_pre · qwen3.6-flash · 0.9s
🛡 pre: uncertain → recommend [KAST: 1d20+3 | DEXTERITY to sneak (DC 14)]
# dm stream · step-3.7-flash · 6.2s · 412 tokens
DM: You press your back to the damp stone, torchlight crawling across the floor… [KAST: 1d20+3 | DEXTERITY to sneak (DC 14)]
# validate · strip tags · parse_roll_requests
⚙ parsed: 1 roll request → dice button spawned in chat
❯ you: [Resultat: 1d20+3 → 17] (button click, auto-sent)
# guardian_post · extraction model · 2.1s · 3 changes
🛡 post: xp:0 · logbook:"Faelyndra smög förbi vakten…" · npcs_near:[Kip] · time_passed:1h
# apply_mechanics → state.json · lock released
✍ saved: turn 42 · hp 21/26 · gp 15 · 2 quests active
§3

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.

01LANGUAGE[LANGUAGE: ENGLISH] / [SPRÅK: SVENSKA] — first line, overrides all Swedish internal notes
02CORE IDENTITYDM_CORE_PROMPT [v28] — storyteller, world-keeper, rules arbiter · version = cache-buster
03ANTI-HALLUCINATIONthe DM triad: say yes · say no · or roll dice — player-invented advantages are refused
04COMBAT or NARRATIVEliving enemies in state.npcs? → DM_COMBAT_PROMPT · else DM_NARRATIVE_PROMPT — weight only what's real
05TRUTH BLOCKHP · AC · gold · inventory · quests · pinned facts — "authoritative, never contradict this"
06MEMORY TIERS2 scene summaries · 2 chapters · 1 campaign arc — each stamped with its turn number
07ACTIVE THREADS3–5 ongoing storylines, re-woven every 10 turns — the map that's always on the table
08LAST EFFECTSmeta.last_effects — "Skada: 12 · Föremål: Torch · Tid: 2h" — react to what you caused
09WORLD & TRAVELlocation · time · weather · known places · travel = distance ÷ 10 × terrain (road 0.5 … mountains 1.8)
10LORE · NPCs · QUESTSworld lore, NPC dossiers with relations, active quests
11SHEET & MEMORYinventory + character · top 8 facts · top 4 semantic chunks (§4)
12LANGUAGE REMINDERthe directive repeated last — models drift, so it bookends the prompt · Guardian PRE advice rides here too

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

DifficultyDC
Easy8–10
Medium12–14
Hard16–18
Very hard20–22
Nearly impossible25+

Routine = auto-success. Pressure raises DC, preparation lowers it.

§4

Fact Memory

Facts are extracted after every turn and ranked before every reply — only the top eight ship. Nothing is deleted; everything competes.

get_relevant_facts · limit 8 · + top 4 RAG chunks
top 8 ship · the rest stay in the register

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.

§5

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

damagehealingdeathcombat_startcombat_roundcombat_endplayer_attacksally_attacksally_damageenemy_attackscombat_eventsinitiative_entries

🔥 Conditions & resources — 8

spell_slots_spendinspiration_gaininspiration_spendexhaustion_changecover_settraining_updateenemy_actionsstatus_apply

⭐ Progression — 3

xpspells_addcharacter_updates

🪙 Possessions — 3

items_additems_removecurrency

⚑ Quests — 3

quests_newquests_completedquests_failed

👥 People — 5

npcs_newnpc_relationsnpcs_nearnpc_notesnpc_name_reveals

🗺 World & time — 8

locations_newcurrent_locationworld_loretime_passedrestnew_dayday_summarylogbook

✎ Grants & fixes — 2

roll_grantscorrections
guardian_extract_mechanics → JSON

  

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.

§6

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.

✦ dice console — same notation parser as the backend

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.

§7

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.

✦ spell_slots {current · max} — live pool
4/4 — the well is full

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.

§8

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.

tts_cache_sim · _TTS_CACHE · max 64 · ttl 600s
$ cache empty — first request will miss
§9

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.

sse_frames · POST /api/vault/generate/stream
{"prompt":"a scarred ranger who hunts the things that hunt villages","lang":"en"}
data: {"type":"reasoning","text":"The player wants a ranger with a scar, a hunter… I will build…"}
data: {"type":"content","text":"{\"name\":\"Faelyndra\",\"race\":\"Half-elf\","}
data: {"type":"content","text":"\"class\":\"Ranger\",\"level\":1,\"hp\":{\"max\":12},"}
data: {"type":"content","text":"\"abilities\":{\"DEX\":{\"score\":16,\"mod\":3}},"}
data: {"type":"content","text":"\"story\":\"She carries the scar of the hollow…\"}"}
data: {"type":"done","ok":true,"time_s":31.2,"tokens":{…},"character":{…},"inventory":[{…longsword…},{…shortbow…}]}
# frontend → preview card → [Save to Vault] [Unravel & Reroll]
§10

The State Model

One JSON file per campaign is the single source of truth. Everything you see is a projection of it.

state.json ONE FILE · ASYNCIO LOCK · JOURNALED TRANSCRIPTS CHARACTER SHEET NPC DOSSIERS THE MAP LOGBOOK NEXT DM LINE COMBAT RAIL GUARDIAN WRITES DM READS A DIGEST · NEVER INVENTS
read — projection write — Guardian only

The shape

meta — models · turn_count · last_effects · epoch
character — the sheet
hp{current·max·temp} · xp · spell_slots{current·max}
abilities STR…CHA · hit_dice{dice·total·remaining}
inventory[] · spells[] · updates[] (Guardian growth log)
world — the living world
current_location · time · weather · day
logbook[] · travel_log[] · day_summaries[]
combat — chat-first battle state
enemies[]{hp·ac·alive·statuses} · allies[]
round · initiative[] · turn_order[] · log[]
player_actions{action·bonus·reaction} — one each per round
npcs[] · quests[] · locations[]
npcs[] name · relation · notes · alive · near (recomputed every turn)
quests[] id · reward · xp_reward · status · completed_turn
locations[] name · terrain · x · y — the map is a projection of this
lore[] · pinned_facts[] · avatars{} · images[]

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
lvl 1→2: 300 XPlvl 9→10: 64,000 XPlvl 19→20: 355,000 XP

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.

§11

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.

costs 1 turn · no refund · ledger records action=undo

turn 41 · snapshot

hp 26/26 · gp 12
quests: 2 active
facts: 34 · threads: 4

turn 42 · written

hp 21/26 · gp 15
logbook +1 · npcs_near:[Kip]
time_passed: 1h
🛡 late guardian job…

after rewind

epoch 7 — background writers check it before saving

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.