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
This commit is contained in:
parththakkar106
2026-08-10 19:21:26 +05:30
co-authored by Claude Opus 5
parent ebb81b8f9a
commit 93204ce1b0
2 changed files with 251 additions and 0 deletions
+2
View File
@@ -11,6 +11,8 @@ scripting**.
> ### ▶️ Try it live: **[ai-dnd-1gmp.onrender.com](https://ai-dnd-1gmp.onrender.com)** > ### ▶️ Try it live: **[ai-dnd-1gmp.onrender.com](https://ai-dnd-1gmp.onrender.com)**
> Play a demo scenario as a guest — no sign-up, no API key needed. (Hosted on Render's free > Play a demo scenario as a guest — no sign-up, no API key needed. (Hosted on Render's free
> tier, so the first load after it's been idle takes ~30–60s to wake up.) > tier, so the first load after it's been idle takes ~30–60s to wake up.)
>
> Prefer a tour first? The **[project page](https://parththakkar106.github.io/AI-DnD/)** loads instantly.
Built with FastAPI + SQLAlchemy on the backend and React (Vite) on the frontend, running on Built with FastAPI + SQLAlchemy on the backend and React (Vite) on the frontend, running on
SQLite locally and Postgres in the cloud. Works with **any OpenAI-compatible endpoint**: Ollama SQLite locally and Postgres in the cloud. Works with **any OpenAI-compatible endpoint**: Ollama
+249
View File
@@ -0,0 +1,249 @@
<!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>