The demo sleeps on Render's free tier, so a cold link looks broken to anyone who won't wait 30-60s. A static page on GitHub Pages is never asleep: it shows the screenshots immediately and sets the expectation before the visitor clicks through to the demo. Served from main:/docs, reusing the screenshots already committed there. Palette and type match the app so the two read as one product. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sygnUWH8WnaoKPDp7hXM1
250 lines
13 KiB
HTML
250 lines
13 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="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.</figcaption>
|
||
</figure>
|
||
</header>
|
||
|
||
<section>
|
||
<h2>What makes it more than a chat wrapper</h2>
|
||
<p class="lede">Three things a plain "talk to a model" app doesn't do.</p>
|
||
|
||
<div class="grid">
|
||
<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] + [story history, 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">151</div><div class="l">backend tests, run by CI on every push</div></div>
|
||
<div class="stat"><div class="n">37</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>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>
|