Files
interactive-story/docs/index.html
T
parththakkar106andClaude Opus 5 93204ce1b0 Add a project page so the resume link loads instantly
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
2026-08-10 19:21:26 +05:30

250 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AI D&amp;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&amp;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&amp;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 &amp; 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>