The README, the project page and the engineering guide all describe a linear story. The tree shipped two days ago. Every published surface is a phase behind, and the guide is not merely behind — it is wrong in a way that costs a reader time. Its 2.2 was "Two coordinate systems, and the bug class they create", and it explained the codebase through position_of_index, note_action_removed and settled_story_actions. All three were deleted in SP3. 2.3 explained retry through Action.variants and state_before. Somebody reading either would go looking for machinery that is not there, which is worse than a gap. So 2.2 is now "The story is a tree", written at the depth 1.2 and 1.3 are written at: the seven bugs that turned out to be one bug, the lineage clause and the two properties that make fork count free, why takes group by parent_id rather than by coordinate, cursors becoming anchors, and a closing list of what the design is honest about. 2.3 is rewritten around state_after and takes, and 1.1 and 1.5 follow, because the pipeline no longer snapshots before the call and the memory bank no longer holds an action back. The numbers were simply old: 151 tests where there are 440, 37 migrations where there are 64, twelve phases where there are fourteen. They appear in four places across the README, the project page's stat tiles and the guide's results table. The measured branch cost — 103 B, and 1.007x the page load of the same story flat — is added beside the egress and turn-cost figures it belongs with, since it is the number that answers "what does branching cost me". Three screenshots, on a new tools/shots_fixture.py: the Bandit Camp demo driven through eight written turns with written deltas, three discarded takes forked onto branches of their own, one off a branch so the map has to nest. Same reason tree_fixture.py is committed — the shots have to be reproducible and the frontend still has no test runner. play-world-state.jpg is reshot because it predates the entire tree UI; the map and the branches panel are new. Note for next time: docs/guide.html is hand-written, not generated from the Markdown, so every guide edit is two edits in two vocabularies. Both files were checked for tag balance and both pages rendered locally before this landed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfMCsN1KBLsTqMkj5hSgrY
267 lines
15 KiB
HTML
267 lines
15 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>AI D&D — an AI Dungeon-style storytelling engine</title>
|
||
<meta name="description" content="An AI Dungeon-style interactive storytelling app. FastAPI + React, any OpenAI-compatible model, an RPG world-state engine the AI proposes and Python referees, and a quickjs sandbox that runs real AI Dungeon scripts.">
|
||
<meta property="og:title" content="AI D&D — an AI Dungeon-style storytelling engine">
|
||
<meta property="og:description" content="Play open-ended adventures narrated by an LLM, with a world-state engine that keeps the numbers honest.">
|
||
<meta property="og:image" content="https://parththakkar106.github.io/AI-DnD/images/play-world-state.jpg">
|
||
<meta property="og:type" content="website">
|
||
<meta name="twitter:card" content="summary_large_image">
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%E2%9A%94%3C/text%3E%3C/svg%3E">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg: #0a0a0f;
|
||
--panel: #131320;
|
||
--border: #2b2b3d;
|
||
--border-bright: #3d3d55;
|
||
--text: #e2ddd0;
|
||
--dim: #918c7d;
|
||
--accent: #d4a94e;
|
||
--accent-bright: #e8c476;
|
||
--accent-dim: #96773a;
|
||
--display: 'Cinzel', Georgia, serif;
|
||
--ui: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||
color-scheme: dark;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
background:
|
||
radial-gradient(1200px 700px at 15% -10%, rgba(212,169,78,.06), transparent 60%),
|
||
radial-gradient(1000px 600px at 90% 110%, rgba(88,76,140,.08), transparent 55%),
|
||
var(--bg);
|
||
background-attachment: fixed;
|
||
color: var(--text);
|
||
font-family: var(--ui);
|
||
line-height: 1.65;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 24px; }
|
||
a { color: var(--accent-bright); }
|
||
|
||
header { padding: 72px 0 40px; text-align: center; }
|
||
.mark { font-family: var(--display); font-size: 14px; letter-spacing: .28em; color: var(--accent); text-transform: uppercase; }
|
||
h1 {
|
||
font-family: var(--display); font-weight: 700;
|
||
font-size: clamp(2.4rem, 6vw, 4rem); margin: .2em 0 .1em; letter-spacing: .02em;
|
||
background: linear-gradient(180deg, var(--accent-bright), var(--accent));
|
||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||
}
|
||
.tagline { font-size: clamp(1.05rem, 2.2vw, 1.3rem); color: var(--text); max-width: 46ch; margin: .6em auto 0; }
|
||
.sub { color: var(--dim); max-width: 60ch; margin: 1em auto 0; font-size: .97rem; }
|
||
|
||
.cta { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; margin: 32px 0 10px; }
|
||
.btn {
|
||
display: inline-block; padding: 13px 26px; border-radius: 8px; text-decoration: none;
|
||
font-weight: 600; font-size: 1rem; border: 1px solid var(--border-bright); transition: .18s;
|
||
}
|
||
.btn-primary { background: linear-gradient(180deg, var(--accent-bright), var(--accent)); color: #17130a; border-color: var(--accent); }
|
||
.btn-primary:hover { filter: brightness(1.08); transform: translateY(-1px); }
|
||
.btn-ghost { background: var(--panel); color: var(--text); }
|
||
.btn-ghost:hover { border-color: var(--accent); color: var(--accent-bright); }
|
||
.wake { color: var(--dim); font-size: .85rem; text-align: center; margin-top: 4px; }
|
||
|
||
figure { margin: 0; }
|
||
figure img {
|
||
width: 100%; height: auto; display: block; border-radius: 10px;
|
||
border: 1px solid var(--border); box-shadow: 0 24px 60px rgba(0,0,0,.55);
|
||
}
|
||
figcaption { color: var(--dim); font-size: .88rem; margin-top: 12px; }
|
||
.hero-shot { margin: 44px 0 8px; }
|
||
|
||
section { padding: 56px 0; border-top: 1px solid var(--border); margin-top: 56px; }
|
||
h2 {
|
||
font-family: var(--display); font-size: 1.6rem; font-weight: 700;
|
||
color: var(--accent); margin: 0 0 8px; letter-spacing: .02em;
|
||
}
|
||
.lede { color: var(--dim); margin: 0 0 32px; max-width: 68ch; }
|
||
|
||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 32px; }
|
||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 22px; }
|
||
.card h3 { font-family: var(--display); font-size: 1.12rem; margin: 0 0 8px; color: var(--text); }
|
||
.card p { margin: 0 0 16px; color: var(--dim); font-size: .95rem; }
|
||
.card p:last-child { margin-bottom: 0; }
|
||
.card img { border-radius: 8px; border: 1px solid var(--border); width: 100%; height: auto; display: block; }
|
||
|
||
pre {
|
||
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
||
padding: 20px; overflow-x: auto; font-size: .86rem; line-height: 1.7; color: var(--text);
|
||
}
|
||
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||
|
||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 20px; margin-top: 8px; }
|
||
.stat { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
|
||
.stat .n { font-family: var(--display); font-size: 1.9rem; color: var(--accent-bright); line-height: 1.1; }
|
||
.stat .l { color: var(--dim); font-size: .88rem; margin-top: 6px; }
|
||
|
||
ul.notes { padding-left: 0; list-style: none; margin: 0; }
|
||
ul.notes li { border-left: 2px solid var(--accent-dim, #96773a); padding: 2px 0 2px 18px; margin-bottom: 22px; color: var(--dim); }
|
||
ul.notes strong { color: var(--text); }
|
||
|
||
.stack { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; }
|
||
.chip { background: var(--panel); border: 1px solid var(--border); border-radius: 999px; padding: 5px 14px; font-size: .85rem; color: var(--dim); }
|
||
|
||
footer { border-top: 1px solid var(--border); margin-top: 56px; padding: 36px 0 64px; color: var(--dim); font-size: .9rem; text-align: center; }
|
||
|
||
@media (max-width: 720px) {
|
||
header { padding: 48px 0 24px; }
|
||
section { padding: 40px 0; margin-top: 40px; }
|
||
.btn { width: 100%; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="wrap">
|
||
|
||
<header>
|
||
<div class="mark">⚔ Interactive fiction, refereed</div>
|
||
<h1>AI D&D</h1>
|
||
<p class="tagline">Open-ended adventures narrated by an LLM — with an engine that keeps the numbers honest.</p>
|
||
<p class="sub">Create a world, play it in second person, and let the model improvise the story while a Python
|
||
referee enforces what's actually true: hit points, an ally's trust, a raised alarm, a quest milestone.
|
||
Bring your own model, or play the demo with none.</p>
|
||
|
||
<div class="cta">
|
||
<a class="btn btn-primary" href="https://ai-dnd-1gmp.onrender.com">Launch the live demo →</a>
|
||
<a class="btn btn-ghost" href="guide.html">Read the design notes</a>
|
||
<a class="btn btn-ghost" href="https://github.com/parththakkar106/AI-DnD">View the source</a>
|
||
</div>
|
||
<p class="wake">No sign-up, no API key. Hosted on a free tier that sleeps — the first load takes ~30–60s to wake.</p>
|
||
|
||
<figure class="hero-shot">
|
||
<img src="images/play-world-state.jpg" alt="The play screen with the world-state rail open, showing HP, mana, an NPC's trust and a raised alarm flag">
|
||
<figcaption>The left rail is live world state. The model proposes what changed this turn; the engine decides
|
||
what sticks, and the chip under the narration reports the result. The <code>‹ 2/2 ›</code>
|
||
under a turn steps between the takes it has.</figcaption>
|
||
</figure>
|
||
</header>
|
||
|
||
<section>
|
||
<h2>What makes it more than a chat wrapper</h2>
|
||
<p class="lede">Four things a plain "talk to a model" app doesn't do.</p>
|
||
|
||
<div class="grid">
|
||
<div class="card">
|
||
<h3>The story is a tree</h3>
|
||
<p>Any turn can hold more than one <em>take</em>. Stepping between them is free — the story below simply
|
||
empties, and the server is told nothing. Writing below a take that isn't the live one is what makes a
|
||
branch, and a branch stores no turns of its own: it records where it left its parent and borrows
|
||
everything above that. Twenty forks cost 1.007× the page load of the same story flat. Switch lines and
|
||
the world state, the script scoreboard and the cooldown clocks all come back to what that line left.</p>
|
||
<img src="images/branch-map.jpg" alt="The branch map: one horizontal lane per line of the story, each joined to its parent by an elbow at the moment it forked">
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>The AI proposes, Python referees</h3>
|
||
<p>A scenario declares stats, flags, milestones and a named cast. Each turn the model appends the changes
|
||
it thinks happened — and the engine clamps them to range, enforces per-turn caps and cooldowns, keeps
|
||
counters monotonic and milestones sticky, then strips the machine-readable block out of the prose.
|
||
Word-labelled bands (<code>40–60: minor damage</code>) are what make the model reliable at it.
|
||
No dice, no scripting required.</p>
|
||
<img src="images/scenario-editor-npcs.jpg" alt="The scenario editor showing NPC stats with ranges, per-turn caps, cooldowns and labelled bands">
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>You can see the entire prompt</h3>
|
||
<p>Every turn stores exactly what was sent to the model. Open Insights on any action to see each context
|
||
component, what it cost in tokens, and why it was there — including which trigger word pulled in each
|
||
story card and the similarity score behind each retrieved memory.</p>
|
||
<img src="images/insights.jpg" alt="The Insights panel showing the assembled prompt broken into components with token counts">
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Real AI Dungeon scripts run</h3>
|
||
<p>The three familiar hooks — <code>onInput</code>, <code>onModelContext</code>, <code>onOutput</code> —
|
||
with shared persistent <code>state</code> and a <code>worldEntries</code> API, executed in an embedded
|
||
quickjs sandbox. Scripts written for AI Dungeon import and work, and there's a CodeMirror editor in the app.</p>
|
||
<img src="images/script-editor.jpg" alt="The in-app script editor showing an input hook written in JavaScript">
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Memory that survives a long story</h3>
|
||
<p>The modern AI Dungeon memory system: AI-generated memories every few actions, a running story summary,
|
||
and embedding-based retrieval that pulls an old-but-relevant fact back into context when it matters.
|
||
Undo and retry roll the world state back to a per-action snapshot rather than only rewriting the text.
|
||
Every story stays where you left it, and the home page opens on its most recent line.</p>
|
||
<img src="images/home.jpg" alt="The home page, showing stories in progress alongside scenarios to start from">
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>How a turn works</h2>
|
||
<p class="lede">Player input goes through the script pipeline, into a token-budgeted context, out to whichever
|
||
model you configured, and back through the referee.</p>
|
||
<pre><code>player input
|
||
→ onInput script modifier
|
||
→ assemble context: [narrator prompt] + [world state + stat guide] + [AI instructions]
|
||
+ [plot essentials] + [story summary] + [retrieved memories]
|
||
+ [triggered story cards] + [history along this branch, token-budgeted]
|
||
+ [author's note] + [player action]
|
||
→ onModelContext script modifier
|
||
→ snapshot context (Insights)
|
||
→ provider adapter → AI (streamed)
|
||
→ extract + referee the world-state delta block, strip it from the prose
|
||
→ onOutput script modifier
|
||
→ store & render</code></pre>
|
||
|
||
<div class="stack">
|
||
<span class="chip">FastAPI</span>
|
||
<span class="chip">SQLAlchemy</span>
|
||
<span class="chip">React + Vite</span>
|
||
<span class="chip">Postgres / SQLite</span>
|
||
<span class="chip">quickjs sandbox</span>
|
||
<span class="chip">Server-sent events</span>
|
||
<span class="chip">Docker</span>
|
||
<span class="chip">Any OpenAI-compatible endpoint</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Engineering notes</h2>
|
||
<p class="lede">The parts that were measured rather than guessed at.</p>
|
||
|
||
<div class="stats">
|
||
<div class="stat"><div class="n">189×</div><div class="l">less database egress per adventure load</div></div>
|
||
<div class="stat"><div class="n">440</div><div class="l">backend tests, run by CI on every push</div></div>
|
||
<div class="stat"><div class="n">64</div><div class="l">schema migrations, applied in order on boot</div></div>
|
||
<div class="stat"><div class="n">$0</div><div class="l">to run it locally against Ollama</div></div>
|
||
</div>
|
||
|
||
<ul class="notes" style="margin-top:34px">
|
||
<li><strong>Database egress, cut ~189×.</strong> Every adventure load was pulling the entire assembled
|
||
prompt — about 74 KB per turn — just to read two small fields off it. Moving those into their own columns
|
||
and deferring the heavy ones took one load from 38.5 MB to 0.20 MB. A test hooks into SQLAlchemy's cursor
|
||
events and fails if a bulk load ever names those columns again.</li>
|
||
<li><strong>Turn cost, made flat.</strong> Assembling a turn walked the whole story, so it grew with story
|
||
length — 839 KB of reads by turn 200. History is now served as tails and slices from SQL: the same turn
|
||
costs 129 KB and stops growing at around turn 50.</li>
|
||
<li><strong>Branching that costs 103 bytes.</strong> A branch stores where it left its parent and borrows
|
||
every turn above that, so nothing is copied on a fork. A 40-turn story forked twenty times loads in
|
||
31,652 B against 31,433 B for the same story flat — 1.007×. Reads stay cheap because the ancestry is
|
||
windowed the way the history is: the number of SQL clauses is bounded by the context window, not by how
|
||
many times the story has forked.</li>
|
||
<li><strong>A shared demo key that can't be drained.</strong> The hosted demo funds a model for visitors, so
|
||
model selection is pinned server-side with a structural backstop that raises if any code path tries to
|
||
resolve a model outside the allowed set — plus a daily per-visitor turn cap.</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<footer>
|
||
<p>Built by <a href="https://github.com/parththakkar106">Parth Thakkar</a> ·
|
||
<a href="https://github.com/parththakkar106/AI-DnD">Source on GitHub</a> ·
|
||
<a href="https://github.com/parththakkar106/AI-DnD/blob/main/LICENSE">MIT</a></p>
|
||
<p>Run it yourself with one command: <code>docker compose up --build</code></p>
|
||
</footer>
|
||
|
||
</div>
|
||
</body>
|
||
</html>
|