fuzzyworld

dm / commit

feat: add solo D&D web app frontend

91c717f909250d772a69ba5ae36050bd4ecb4046 · Pavel Safronov · 2026-05-27 00:00:37 -0700 · parents cfd2ab1793b0

feat: add solo D&D web app frontend

Character creation screen with race/class card grids (10 each).
Game screen with streaming DM chat, clickable numbered options,
character sheet sidebar (HP/XP bars, gold, inventory), dice roller
(d4–d100), and quick-action buttons. Uses dice.png as favicon and
title icon with attribution to Icongeek26 on Flaticon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 public/index.html | 1349 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 1349 insertions(+)

diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..31261d6
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,1349 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Solo Dungeon Master</title>
+<link rel="icon" type="image/png" href="/dice.png">
+<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
+<style>
+:root {
+  --bg: #0d0d1a;
+  --bg-panel: #11111e;
+  --bg-sidebar: #0c1220;
+  --bg-card: #181826;
+  --bg-dm: #14142a;
+  --bg-player: #1a0f28;
+  --bg-input: #0a0a18;
+  --gold: #c9a84c;
+  --gold-light: #e8c97a;
+  --gold-dim: #7a5c20;
+  --text: #d0be98;
+  --text-dim: #6a5a42;
+  --text-bright: #eedcb8;
+  --border: #1e1e34;
+  --border-gold: #3a2a0e;
+  --hp-full: #2ecc71;
+  --hp-med: #f39c12;
+  --hp-low: #e74c3c;
+  --accent: #6b4fbb;
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+  background: var(--bg);
+  color: var(--text);
+  font-family: 'Crimson Text', Georgia, serif;
+  font-size: 17px;
+  line-height: 1.6;
+  height: 100vh;
+  overflow: hidden;
+}
+
+/* ── CREATION SCREEN ─────────────────────────────── */
+
+#creation-screen {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: flex-start;
+  min-height: 100vh;
+  overflow-y: auto;
+  padding: 40px 20px 60px;
+  background:
+    radial-gradient(ellipse at 50% 0%, rgba(107,79,187,0.18) 0%, transparent 60%),
+    radial-gradient(ellipse at 20% 80%, rgba(201,168,76,0.08) 0%, transparent 50%),
+    var(--bg);
+}
+
+.creation-title {
+  font-family: 'Cinzel', serif;
+  font-size: 3rem;
+  font-weight: 700;
+  color: var(--gold-light);
+  text-align: center;
+  letter-spacing: 0.08em;
+  text-shadow: 0 0 40px rgba(201,168,76,0.4), 0 2px 8px rgba(0,0,0,0.8);
+  margin-bottom: 6px;
+}
+
+.creation-sub {
+  color: var(--text-dim);
+  font-style: italic;
+  font-size: 1.1rem;
+  text-align: center;
+  margin-bottom: 40px;
+  letter-spacing: 0.05em;
+}
+
+.creation-section {
+  width: 100%;
+  max-width: 700px;
+  margin-bottom: 30px;
+}
+
+.section-label {
+  font-family: 'Cinzel', serif;
+  font-size: 0.8rem;
+  letter-spacing: 0.15em;
+  color: var(--gold);
+  text-transform: uppercase;
+  margin-bottom: 12px;
+  padding-bottom: 6px;
+  border-bottom: 1px solid var(--border-gold);
+}
+
+.selection-grid {
+  display: grid;
+  grid-template-columns: repeat(5, 1fr);
+  gap: 8px;
+}
+
+.selection-grid.class-grid {
+  grid-template-columns: repeat(5, 1fr);
+}
+
+.sel-card {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+  padding: 12px 8px;
+  background: var(--bg-card);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.15s;
+  user-select: none;
+}
+
+.sel-card:hover {
+  border-color: var(--gold-dim);
+  background: #1e1e30;
+}
+
+.sel-card.selected {
+  border-color: var(--gold);
+  background: #1e1a0a;
+  box-shadow: 0 0 12px rgba(201,168,76,0.2);
+}
+
+.sel-card .icon { font-size: 1.6rem; }
+.sel-card .name {
+  font-family: 'Cinzel', serif;
+  font-size: 0.65rem;
+  letter-spacing: 0.05em;
+  color: var(--text-dim);
+  text-align: center;
+}
+
+.sel-card.selected .name { color: var(--gold-light); }
+
+.text-fields {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+}
+
+.field-group label {
+  display: block;
+  font-family: 'Cinzel', serif;
+  font-size: 0.7rem;
+  letter-spacing: 0.12em;
+  color: var(--gold);
+  text-transform: uppercase;
+  margin-bottom: 6px;
+}
+
+.field-group input {
+  width: 100%;
+  background: var(--bg-input);
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  color: var(--text-bright);
+  font-family: 'Crimson Text', serif;
+  font-size: 1rem;
+  padding: 10px 14px;
+  outline: none;
+  transition: border-color 0.15s;
+}
+
+.field-group input:focus { border-color: var(--gold-dim); }
+.field-group input::placeholder { color: var(--text-dim); font-style: italic; }
+
+.begin-btn {
+  display: block;
+  width: 100%;
+  max-width: 700px;
+  padding: 16px;
+  font-family: 'Cinzel', serif;
+  font-size: 1.1rem;
+  font-weight: 600;
+  letter-spacing: 0.12em;
+  color: #0d0d1a;
+  background: linear-gradient(135deg, var(--gold) 0%, var(--gold-light) 50%, var(--gold) 100%);
+  border: none;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.2s;
+  text-transform: uppercase;
+  box-shadow: 0 4px 20px rgba(201,168,76,0.3);
+  margin-top: 8px;
+}
+
+.begin-btn:hover:not(:disabled) {
+  transform: translateY(-1px);
+  box-shadow: 0 6px 28px rgba(201,168,76,0.45);
+}
+
+.begin-btn:disabled {
+  opacity: 0.4;
+  cursor: not-allowed;
+}
+
+/* ── GAME SCREEN ────────────────────────────────── */
+
+#game-screen {
+  display: none;
+  height: 100vh;
+  flex-direction: row;
+}
+
+#game-screen.visible { display: flex; }
+
+/* Chat panel */
+.chat-panel {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  background: var(--bg-panel);
+  border-right: 1px solid var(--border);
+}
+
+.chat-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12px 20px;
+  border-bottom: 1px solid var(--border);
+  background: var(--bg-card);
+  flex-shrink: 0;
+}
+
+.chat-header-title {
+  font-family: 'Cinzel', serif;
+  font-size: 0.85rem;
+  letter-spacing: 0.12em;
+  color: var(--gold);
+  text-transform: uppercase;
+}
+
+.new-game-btn {
+  font-family: 'Cinzel', serif;
+  font-size: 0.65rem;
+  letter-spacing: 0.1em;
+  color: var(--text-dim);
+  background: transparent;
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  padding: 5px 12px;
+  cursor: pointer;
+  transition: all 0.15s;
+  text-transform: uppercase;
+}
+
+.new-game-btn:hover { border-color: var(--gold-dim); color: var(--gold); }
+
+#messages {
+  flex: 1;
+  overflow-y: auto;
+  padding: 20px;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  scrollbar-width: thin;
+  scrollbar-color: var(--border) transparent;
+}
+
+#messages::-webkit-scrollbar { width: 4px; }
+#messages::-webkit-scrollbar-track { background: transparent; }
+#messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
+
+/* Message styles */
+.msg { display: flex; flex-direction: column; gap: 4px; }
+
+.msg-dm {
+  border-left: 2px solid var(--gold-dim);
+  background: var(--bg-dm);
+  border-radius: 0 6px 6px 0;
+  padding: 14px 18px;
+}
+
+.msg-player {
+  align-self: flex-end;
+  max-width: 75%;
+  background: var(--bg-player);
+  border: 1px solid #2d1a40;
+  border-radius: 6px 6px 0 6px;
+  padding: 10px 16px;
+  color: #c0a8e0;
+  font-style: italic;
+}
+
+.msg-system {
+  align-self: center;
+  text-align: center;
+  color: var(--gold);
+  font-family: 'Cinzel', serif;
+  font-size: 0.78rem;
+  letter-spacing: 0.1em;
+  padding: 6px 16px;
+  border: 1px solid var(--border-gold);
+  border-radius: 20px;
+  background: rgba(201,168,76,0.05);
+}
+
+.msg-dm p { margin-bottom: 10px; }
+.msg-dm p:last-child { margin-bottom: 0; }
+.msg-dm .options { margin-top: 14px; display: flex; flex-direction: column; gap: 6px; }
+.msg-dm .option {
+  display: flex;
+  gap: 10px;
+  padding: 8px 12px;
+  background: rgba(201,168,76,0.06);
+  border: 1px solid var(--border-gold);
+  border-radius: 4px;
+  cursor: pointer;
+  transition: all 0.15s;
+  align-items: flex-start;
+}
+.msg-dm .option:hover { background: rgba(201,168,76,0.12); border-color: var(--gold-dim); }
+.msg-dm .option .opt-num {
+  font-family: 'Cinzel', serif;
+  font-size: 0.8rem;
+  color: var(--gold);
+  font-weight: 600;
+  min-width: 18px;
+  margin-top: 1px;
+}
+.msg-dm .option .opt-text { color: var(--text-bright); }
+
+.typing-dots {
+  display: flex;
+  gap: 5px;
+  padding: 8px 0 4px;
+  align-items: center;
+}
+.typing-dots span {
+  width: 6px; height: 6px;
+  background: var(--gold-dim);
+  border-radius: 50%;
+  animation: dot-pulse 1.4s infinite ease-in-out;
+}
+.typing-dots span:nth-child(2) { animation-delay: 0.2s; }
+.typing-dots span:nth-child(3) { animation-delay: 0.4s; }
+
+@keyframes dot-pulse {
+  0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
+  40% { opacity: 1; transform: scale(1); }
+}
+
+/* Quick actions */
+.quick-actions {
+  display: flex;
+  gap: 8px;
+  padding: 10px 20px;
+  border-top: 1px solid var(--border);
+  flex-wrap: wrap;
+  flex-shrink: 0;
+}
+
+.quick-btn {
+  font-family: 'Crimson Text', serif;
+  font-size: 0.88rem;
+  color: var(--text-dim);
+  background: var(--bg-card);
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  padding: 5px 12px;
+  cursor: pointer;
+  transition: all 0.15s;
+  white-space: nowrap;
+}
+.quick-btn:hover:not(:disabled) { border-color: var(--gold-dim); color: var(--text); }
+.quick-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+
+/* Input area */
+.input-area {
+  display: flex;
+  gap: 10px;
+  padding: 14px 20px;
+  border-top: 1px solid var(--border);
+  background: var(--bg-card);
+  flex-shrink: 0;
+  align-items: flex-end;
+}
+
+#player-input {
+  flex: 1;
+  background: var(--bg-input);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  color: var(--text-bright);
+  font-family: 'Crimson Text', serif;
+  font-size: 1rem;
+  padding: 10px 14px;
+  outline: none;
+  resize: none;
+  min-height: 44px;
+  max-height: 120px;
+  line-height: 1.5;
+  transition: border-color 0.15s;
+}
+#player-input:focus { border-color: var(--gold-dim); }
+#player-input::placeholder { color: var(--text-dim); font-style: italic; }
+
+#send-btn {
+  font-family: 'Cinzel', serif;
+  font-size: 0.8rem;
+  font-weight: 600;
+  letter-spacing: 0.08em;
+  color: #0d0d1a;
+  background: var(--gold);
+  border: none;
+  border-radius: 6px;
+  padding: 10px 20px;
+  cursor: pointer;
+  transition: all 0.15s;
+  white-space: nowrap;
+  align-self: flex-end;
+}
+#send-btn:hover:not(:disabled) { background: var(--gold-light); }
+#send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+/* ── SIDEBAR ────────────────────────────────────── */
+
+.sidebar {
+  width: 300px;
+  min-width: 300px;
+  display: flex;
+  flex-direction: column;
+  background: var(--bg-sidebar);
+  overflow-y: auto;
+  scrollbar-width: thin;
+  scrollbar-color: var(--border) transparent;
+}
+
+.sidebar::-webkit-scrollbar { width: 3px; }
+.sidebar::-webkit-scrollbar-thumb { background: var(--border); }
+
+.sidebar-section {
+  padding: 16px;
+  border-bottom: 1px solid var(--border);
+}
+
+.sidebar-title {
+  font-family: 'Cinzel', serif;
+  font-size: 0.65rem;
+  letter-spacing: 0.15em;
+  color: var(--gold);
+  text-transform: uppercase;
+  margin-bottom: 12px;
+}
+
+/* Character sheet */
+.char-portrait {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.char-icon {
+  font-size: 2.5rem;
+  width: 52px;
+  height: 52px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: var(--bg-card);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  flex-shrink: 0;
+}
+
+.char-identity { flex: 1; min-width: 0; }
+
+.char-name {
+  font-family: 'Cinzel', serif;
+  font-size: 0.95rem;
+  color: var(--text-bright);
+  font-weight: 600;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.char-race-class {
+  font-size: 0.82rem;
+  color: var(--text-dim);
+  font-style: italic;
+}
+
+/* HP bar */
+.stat-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 6px;
+}
+
+.stat-label {
+  font-family: 'Cinzel', serif;
+  font-size: 0.65rem;
+  letter-spacing: 0.1em;
+  color: var(--text-dim);
+  text-transform: uppercase;
+}
+
+.stat-value {
+  font-family: 'Cinzel', serif;
+  font-size: 0.85rem;
+  color: var(--text-bright);
+  font-weight: 600;
+}
+
+.bar-track {
+  width: 100%;
+  height: 8px;
+  background: var(--bg-card);
+  border-radius: 4px;
+  overflow: hidden;
+  margin-bottom: 10px;
+}
+
+.bar-fill {
+  height: 100%;
+  border-radius: 4px;
+  transition: width 0.4s ease, background 0.4s ease;
+}
+
+#hp-fill { background: var(--hp-full); }
+#hp-fill.warning { background: var(--hp-med); }
+#hp-fill.danger {
+  background: var(--hp-low);
+  animation: hp-pulse 1.5s infinite;
+}
+
+@keyframes hp-pulse {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.6; }
+}
+
+#xp-fill { background: var(--accent); }
+
+.gold-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-family: 'Cinzel', serif;
+  font-size: 0.82rem;
+  color: var(--gold);
+}
+
+/* Inventory */
+#inventory-list {
+  list-style: none;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  max-height: 180px;
+  overflow-y: auto;
+}
+
+#inventory-list::-webkit-scrollbar { width: 2px; }
+#inventory-list::-webkit-scrollbar-thumb { background: var(--border); }
+
+#inventory-list li {
+  font-size: 0.88rem;
+  color: var(--text);
+  padding: 3px 0;
+  border-bottom: 1px solid var(--border);
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+#inventory-list li::before {
+  content: '◆';
+  color: var(--gold-dim);
+  font-size: 0.5rem;
+  flex-shrink: 0;
+}
+
+/* Dice roller */
+.dice-types {
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+  gap: 4px;
+  margin-bottom: 10px;
+}
+
+.die-btn {
+  font-family: 'Cinzel', serif;
+  font-size: 0.68rem;
+  font-weight: 600;
+  color: var(--text-dim);
+  background: var(--bg-card);
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  padding: 6px 2px;
+  cursor: pointer;
+  transition: all 0.15s;
+  text-align: center;
+}
+
+.die-btn:hover { border-color: var(--gold-dim); color: var(--text); }
+.die-btn.selected { border-color: var(--gold); color: var(--gold); background: rgba(201,168,76,0.08); }
+
+.dice-controls {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  margin-bottom: 10px;
+}
+
+.dice-count-label {
+  font-family: 'Cinzel', serif;
+  font-size: 0.65rem;
+  color: var(--text-dim);
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  white-space: nowrap;
+}
+
+#dice-count {
+  width: 52px;
+  background: var(--bg-input);
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  color: var(--text-bright);
+  font-family: 'Cinzel', serif;
+  font-size: 0.9rem;
+  padding: 5px 8px;
+  text-align: center;
+  outline: none;
+}
+
+#dice-count:focus { border-color: var(--gold-dim); }
+
+#roll-btn {
+  flex: 1;
+  font-family: 'Cinzel', serif;
+  font-size: 0.75rem;
+  font-weight: 600;
+  letter-spacing: 0.08em;
+  color: #0d0d1a;
+  background: var(--gold);
+  border: none;
+  border-radius: 4px;
+  padding: 7px 12px;
+  cursor: pointer;
+  transition: background 0.15s;
+}
+
+#roll-btn:hover:not(:disabled) { background: var(--gold-light); }
+#roll-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+#dice-result {
+  font-family: 'Cinzel', serif;
+  font-size: 1.1rem;
+  font-weight: 700;
+  text-align: center;
+  color: var(--gold-light);
+  min-height: 24px;
+  letter-spacing: 0.05em;
+}
+
+#dice-result-detail {
+  font-size: 0.78rem;
+  text-align: center;
+  color: var(--text-dim);
+  min-height: 18px;
+  font-style: italic;
+}
+
+/* Level up toast */
+#levelup-toast {
+  display: none;
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 100;
+  background: linear-gradient(135deg, #1a1800, #2a2200);
+  border: 2px solid var(--gold);
+  border-radius: 12px;
+  padding: 32px 48px;
+  text-align: center;
+  box-shadow: 0 0 60px rgba(201,168,76,0.4), 0 20px 60px rgba(0,0,0,0.8);
+  animation: toast-appear 0.3s ease;
+}
+
+@keyframes toast-appear {
+  from { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
+  to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
+}
+
+#levelup-toast .toast-icon { font-size: 3rem; margin-bottom: 8px; }
+#levelup-toast .toast-title {
+  font-family: 'Cinzel', serif;
+  font-size: 1.6rem;
+  font-weight: 700;
+  color: var(--gold-light);
+  text-shadow: 0 0 20px rgba(201,168,76,0.6);
+  margin-bottom: 4px;
+}
+#levelup-toast .toast-sub {
+  font-style: italic;
+  color: var(--text-dim);
+  margin-bottom: 20px;
+}
+.attribution {
+  font-size: 0.72rem;
+  color: var(--text-dim);
+  text-align: center;
+}
+.attribution a {
+  color: var(--text-dim);
+  text-decoration: underline;
+  text-underline-offset: 2px;
+}
+.attribution a:hover { color: var(--gold); }
+
+#levelup-toast button {
+  font-family: 'Cinzel', serif;
+  font-size: 0.85rem;
+  letter-spacing: 0.1em;
+  color: #0d0d1a;
+  background: var(--gold);
+  border: none;
+  border-radius: 6px;
+  padding: 10px 28px;
+  cursor: pointer;
+}
+</style>
+</head>
+<body>
+
+<!-- ── CHARACTER CREATION ───────────────────── -->
+<div id="creation-screen">
+  <div class="creation-title"><img src="/dice.png" alt="" style="height:0.85em;vertical-align:middle;margin-right:0.2em;filter:invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(0.9)"> Solo Dungeon Master</div>
+  <div class="creation-sub">Powered by Claude — your story begins here</div>
+
+  <div class="creation-section">
+    <div class="section-label">Choose Your Race</div>
+    <div class="selection-grid" id="race-grid"></div>
+  </div>
+
+  <div class="creation-section">
+    <div class="section-label">Choose Your Class</div>
+    <div class="selection-grid class-grid" id="class-grid"></div>
+  </div>
+
+  <div class="creation-section">
+    <div class="section-label">Your Character</div>
+    <div class="text-fields">
+      <div class="field-group">
+        <label>Character Name (optional)</label>
+        <input type="text" id="char-name" placeholder="e.g. Lyra Dawnseeker" maxlength="40">
+      </div>
+      <div class="field-group">
+        <label>Adventure Theme (optional)</label>
+        <input type="text" id="adventure-theme" placeholder="e.g. haunted sea, ancient ruins" maxlength="60">
+      </div>
+    </div>
+  </div>
+
+  <button class="begin-btn" id="begin-btn" disabled>Begin Adventure</button>
+
+  <p class="attribution">
+    <a href="https://www.flaticon.com/authors/icongeek26" target="_blank" rel="noopener">Dice icon by Icongeek26</a> from
+    <a href="https://www.flaticon.com/" target="_blank" rel="noopener">Flaticon</a>
+  </p>
+</div>
+
+<!-- ── GAME SCREEN ──────────────────────────── -->
+<div id="game-screen">
+
+  <div class="chat-panel">
+    <div class="chat-header">
+      <div class="chat-header-title" id="header-title">Solo Dungeon Master</div>
+      <button class="new-game-btn" id="new-game-btn">✦ New Game</button>
+    </div>
+
+    <div id="messages"></div>
+
+    <div class="quick-actions" id="quick-actions">
+      <button class="quick-btn" data-action="I look around carefully, taking stock of my surroundings.">👁 Look around</button>
+      <button class="quick-btn" data-action="I find a safe spot to rest and recover my strength.">🛌 Rest & recover</button>
+      <button class="quick-btn" data-action="I check my inventory and equipment.">🎒 Check inventory</button>
+      <button class="quick-btn" data-action="I carefully search the area for traps or hidden dangers.">🔍 Search for traps</button>
+      <button class="quick-btn" data-action="I approach the nearest person and try to start a conversation.">💬 Talk to NPC</button>
+    </div>
+
+    <div class="input-area">
+      <textarea id="player-input" placeholder="What do you do?" rows="2"></textarea>
+      <button id="send-btn">⚔ Send</button>
+    </div>
+  </div>
+
+  <div class="sidebar">
+    <!-- Character Sheet -->
+    <div class="sidebar-section">
+      <div class="sidebar-title">Character Sheet</div>
+      <div class="char-portrait">
+        <div class="char-icon" id="char-icon">⚔️</div>
+        <div class="char-identity">
+          <div class="char-name" id="char-name-display">—</div>
+          <div class="char-race-class" id="char-race-class">— / —</div>
+        </div>
+      </div>
+
+      <div class="stat-row">
+        <span class="stat-label">HP</span>
+        <span class="stat-value" id="hp-display">—</span>
+      </div>
+      <div class="bar-track">
+        <div class="bar-fill" id="hp-fill" style="width:100%"></div>
+      </div>
+
+      <div class="stat-row">
+        <span class="stat-label">XP <span id="level-display" style="color:var(--gold)">Lv 1</span></span>
+        <span class="stat-value" id="xp-display">0 / 300</span>
+      </div>
+      <div class="bar-track">
+        <div class="bar-fill" id="xp-fill" style="width:0%"></div>
+      </div>
+
+      <div class="gold-row">
+        <span>🪙</span>
+        <span id="gold-display">10 GP</span>
+      </div>
+    </div>
+
+    <!-- Inventory -->
+    <div class="sidebar-section">
+      <div class="sidebar-title">Inventory</div>
+      <ul id="inventory-list"></ul>
+    </div>
+
+    <!-- Dice Roller -->
+    <div class="sidebar-section">
+      <div class="sidebar-title">Dice Roller</div>
+      <div class="dice-types" id="dice-types"></div>
+      <div class="dice-controls">
+        <span class="dice-count-label">Count:</span>
+        <input type="number" id="dice-count" value="1" min="1" max="10">
+        <button id="roll-btn">🎲 Roll</button>
+      </div>
+      <div id="dice-result"></div>
+      <div id="dice-result-detail"></div>
+      <p class="attribution" style="margin-top:10px">
+        <a href="https://www.flaticon.com/authors/icongeek26" target="_blank" rel="noopener">Dice icon by Icongeek26</a> /
+        <a href="https://www.flaticon.com/" target="_blank" rel="noopener">Flaticon</a>
+      </p>
+    </div>
+  </div>
+</div>
+
+<!-- Level up toast -->
+<div id="levelup-toast">
+  <div class="toast-icon">⭐</div>
+  <div class="toast-title" id="toast-title">LEVEL UP!</div>
+  <div class="toast-sub" id="toast-sub">You have grown stronger.</div>
+  <button onclick="document.getElementById('levelup-toast').style.display='none'">Continue</button>
+</div>
+
+<script>
+// ── DATA ──────────────────────────────────────────────
+
+const RACES = [
+  { name: 'Human',    icon: '👤' },
+  { name: 'Elf',      icon: '🧝' },
+  { name: 'Dwarf',    icon: '⛏️' },
+  { name: 'Tiefling', icon: '😈' },
+  { name: 'Half-Orc', icon: '💪' },
+  { name: 'Gnome',    icon: '🎭' },
+  { name: 'Halfling', icon: '🍃' },
+  { name: 'Dragonborn', icon: '🐉' },
+  { name: 'Half-Elf', icon: '🌟' },
+  { name: 'Aasimar',  icon: '👼' },
+];
+
+const CLASSES = [
+  { name: 'Fighter',   icon: '⚔️',  hp: 10, items: ['Longsword', 'Shield', 'Chain Mail', "Explorer's Pack"] },
+  { name: 'Wizard',    icon: '🔮',  hp: 6,  items: ['Quarterstaff', 'Spellbook', 'Arcane Focus', "Scholar's Pack"] },
+  { name: 'Rogue',     icon: '🗡️',  hp: 8,  items: ['Shortsword', 'Leather Armor', "Thieves' Tools", '2x Daggers'] },
+  { name: 'Paladin',   icon: '⚜️',  hp: 10, items: ['Longsword', 'Shield', 'Chain Mail', 'Holy Symbol'] },
+  { name: 'Bard',      icon: '🎵',  hp: 8,  items: ['Rapier', 'Leather Armor', 'Lute', "Entertainer's Pack"] },
+  { name: 'Ranger',    icon: '🏹',  hp: 10, items: ['Longbow', '20x Arrows', 'Leather Armor', 'Shortsword'] },
+  { name: 'Cleric',    icon: '✨',  hp: 8,  items: ['Mace', 'Shield', 'Scale Mail', 'Holy Symbol'] },
+  { name: 'Druid',     icon: '🌿',  hp: 8,  items: ['Quarterstaff', 'Hide Armor', 'Herbalism Kit', 'Druidic Focus'] },
+  { name: 'Barbarian', icon: '🪓',  hp: 12, items: ['Greataxe', '2x Handaxes', "Explorer's Pack"] },
+  { name: 'Warlock',   icon: '🌑',  hp: 8,  items: ['Dagger', 'Leather Armor', 'Arcane Focus', "Scholar's Pack"] },
+];
+
+const XP_THRESHOLDS = [300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000,
+  85000, 100000, 120000, 140000, 165000, 195000, 225000, 265000, 305000, 355000];
+
+const DIE_TYPES = [4, 6, 8, 10, 12, 20, 100];
+
+// ── STATE ─────────────────────────────────────────────
+
+let selectedRace = null;
+let selectedClass = null;
+let gameState = null;
+let conversationHistory = [];
+let isBusy = false;
+
+// ── CREATION SCREEN ───────────────────────────────────
+
+function buildCreationScreen() {
+  const raceGrid = document.getElementById('race-grid');
+  RACES.forEach(r => {
+    const card = document.createElement('div');
+    card.className = 'sel-card';
+    card.innerHTML = `<span class="icon">${r.icon}</span><span class="name">${r.name}</span>`;
+    card.addEventListener('click', () => {
+      document.querySelectorAll('#race-grid .sel-card').forEach(c => c.classList.remove('selected'));
+      card.classList.add('selected');
+      selectedRace = r;
+      checkBeginReady();
+    });
+    raceGrid.appendChild(card);
+  });
+
+  const classGrid = document.getElementById('class-grid');
+  CLASSES.forEach(c => {
+    const card = document.createElement('div');
+    card.className = 'sel-card';
+    card.innerHTML = `<span class="icon">${c.icon}</span><span class="name">${c.name}</span>`;
+    card.addEventListener('click', () => {
+      document.querySelectorAll('#class-grid .sel-card').forEach(el => el.classList.remove('selected'));
+      card.classList.add('selected');
+      selectedClass = c;
+      checkBeginReady();
+    });
+    classGrid.appendChild(card);
+  });
+
+  document.getElementById('begin-btn').addEventListener('click', beginAdventure);
+}
+
+function checkBeginReady() {
+  document.getElementById('begin-btn').disabled = !(selectedRace && selectedClass);
+}
+
+function beginAdventure() {
+  const name = document.getElementById('char-name').value.trim() || `The ${selectedClass.name}`;
+  const theme = document.getElementById('adventure-theme').value.trim();
+
+  gameState = {
+    character: {
+      name,
+      race: selectedRace.name,
+      class: selectedClass.name,
+      level: 1,
+      hp: selectedClass.hp,
+      maxHp: selectedClass.hp,
+      xp: 0,
+      gold: 10,
+      inventory: [...selectedClass.items],
+    },
+    theme,
+    icon: selectedClass.icon,
+  };
+
+  conversationHistory = [];
+
+  document.getElementById('creation-screen').style.display = 'none';
+  document.getElementById('game-screen').classList.add('visible');
+
+  updateSidebar();
+  buildDiceRoller();
+
+  document.getElementById('header-title').textContent =
+    `${name} — ${selectedRace.name} ${selectedClass.name}`;
+
+  // Auto-send the opening message
+  const opening = `The adventure begins. I stand ready, ${name} — a ${selectedRace.name} ${selectedClass.name}${theme ? ` venturing into ${theme}` : ''}. Set the scene. Bring this world to life.`;
+  sendMessage(opening, false);
+}
+
+// ── SIDEBAR ───────────────────────────────────────────
+
+function updateSidebar() {
+  const c = gameState.character;
+
+  document.getElementById('char-icon').textContent = gameState.icon;
+  document.getElementById('char-name-display').textContent = c.name;
+  document.getElementById('char-race-class').textContent = `${c.race} · ${c.class}`;
+  document.getElementById('level-display').textContent = `Lv ${c.level}`;
+
+  // HP
+  document.getElementById('hp-display').textContent = `${c.hp} / ${c.maxHp}`;
+  const hpPct = Math.max(0, (c.hp / c.maxHp) * 100);
+  const hpFill = document.getElementById('hp-fill');
+  hpFill.style.width = hpPct + '%';
+  hpFill.className = 'bar-fill';
+  if (hpPct <= 25) hpFill.classList.add('danger');
+  else if (hpPct <= 50) hpFill.classList.add('warning');
+
+  // XP
+  const xpNeeded = XP_THRESHOLDS[c.level - 1] || 999999;
+  const prevXp = c.level > 1 ? XP_THRESHOLDS[c.level - 2] : 0;
+  const xpPct = Math.min(100, ((c.xp - prevXp) / (xpNeeded - prevXp)) * 100);
+  document.getElementById('xp-display').textContent = `${c.xp} / ${xpNeeded}`;
+  document.getElementById('xp-fill').style.width = xpPct + '%';
+
+  // Gold
+  document.getElementById('gold-display').textContent = `${c.gold} GP`;
+
+  // Inventory
+  const list = document.getElementById('inventory-list');
+  list.innerHTML = '';
+  c.inventory.forEach(item => {
+    const li = document.createElement('li');
+    li.textContent = item;
+    list.appendChild(li);
+  });
+}
+
+function applyStatUpdate(update) {
+  const c = gameState.character;
+  let leveledUp = false;
+  let newLevel = c.level;
+
+  if (update.hp_change) {
+    c.hp = Math.max(0, Math.min(c.maxHp, c.hp + update.hp_change));
+  }
+  if (update.gold_change) {
+    c.gold = Math.max(0, c.gold + update.gold_change);
+  }
+  if (update.xp_gain) {
+    c.xp += update.xp_gain;
+    while (c.level < 20 && c.xp >= XP_THRESHOLDS[c.level - 1]) {
+      c.level++;
+      c.maxHp += Math.max(1, Math.floor(CLASSES.find(cl => cl.name === c.class)?.hp / 2) + 1);
+      c.hp = Math.min(c.hp + Math.max(1, Math.floor(CLASSES.find(cl => cl.name === c.class)?.hp / 2) + 1), c.maxHp);
+      newLevel = c.level;
+      leveledUp = true;
+    }
+  }
+  if (update.inventory_add?.length) {
+    c.inventory.push(...update.inventory_add);
+  }
+  if (update.inventory_remove?.length) {
+    update.inventory_remove.forEach(item => {
+      const idx = c.inventory.findIndex(i => i.toLowerCase().includes(item.toLowerCase()));
+      if (idx !== -1) c.inventory.splice(idx, 1);
+    });
+  }
+
+  updateSidebar();
+
+  if (update.reason) {
+    addSystemMessage(`⚡ ${update.reason}`);
+  }
+
+  if (leveledUp) {
+    showLevelUp(newLevel);
+  }
+
+  if (c.hp <= 0) {
+    addSystemMessage('💀 You have fallen. Your adventure ends here... for now.');
+    setBusy(true);
+  }
+}
+
+function showLevelUp(level) {
+  const toast = document.getElementById('levelup-toast');
+  document.getElementById('toast-title').textContent = `LEVEL ${level}!`;
+  document.getElementById('toast-sub').textContent = `${gameState.character.name} grows stronger. The world trembles.`;
+  toast.style.display = 'block';
+  setTimeout(() => {
+    if (toast.style.display === 'block') toast.style.display = 'none';
+  }, 6000);
+}
+
+// ── DICE ROLLER ───────────────────────────────────────
+
+let selectedDie = 20;
+
+function buildDiceRoller() {
+  const container = document.getElementById('dice-types');
+  container.innerHTML = '';
+  DIE_TYPES.forEach(d => {
+    const btn = document.createElement('button');
+    btn.className = 'die-btn' + (d === selectedDie ? ' selected' : '');
+    btn.textContent = `d${d}`;
+    btn.addEventListener('click', () => {
+      document.querySelectorAll('.die-btn').forEach(b => b.classList.remove('selected'));
+      btn.classList.add('selected');
+      selectedDie = d;
+    });
+    container.appendChild(btn);
+  });
+
+  document.getElementById('roll-btn').addEventListener('click', rollDice);
+}
+
+function rollDice() {
+  if (isBusy) return;
+  const count = Math.min(10, Math.max(1, parseInt(document.getElementById('dice-count').value, 10) || 1));
+  const rolls = Array.from({ length: count }, () => Math.floor(Math.random() * selectedDie) + 1);
+  const total = rolls.reduce((a, b) => a + b, 0);
+
+  document.getElementById('dice-result').textContent = `${total}`;
+  document.getElementById('dice-result-detail').textContent =
+    count > 1 ? `${count}d${selectedDie}: [${rolls.join(', ')}]` : `d${selectedDie}`;
+
+  const rollMsg = count > 1
+    ? `🎲 I roll ${count}d${selectedDie} — [${rolls.join(', ')}] = **${total}**`
+    : `🎲 I roll a d${selectedDie} — **${total}**`;
+
+  sendMessage(rollMsg, true);
+}
+
+// ── CHAT ─────────────────────────────────────────────
+
+function addSystemMessage(text) {
+  const el = document.createElement('div');
+  el.className = 'msg msg-system';
+  el.textContent = text;
+  document.getElementById('messages').appendChild(el);
+  scrollToBottom();
+}
+
+function addPlayerMessage(text) {
+  const el = document.createElement('div');
+  el.className = 'msg msg-player';
+  el.textContent = text;
+  document.getElementById('messages').appendChild(el);
+  scrollToBottom();
+  return el;
+}
+
+function addDMMessage() {
+  const el = document.createElement('div');
+  el.className = 'msg msg-dm';
+  document.getElementById('messages').appendChild(el);
+
+  const dots = document.createElement('div');
+  dots.className = 'typing-dots';
+  dots.innerHTML = '<span></span><span></span><span></span>';
+  el.appendChild(dots);
+
+  scrollToBottom();
+  return el;
+}
+
+function updateDMMessage(el, rawText) {
+  el.style.whiteSpace = 'pre-wrap';
+  el.textContent = rawText;
+  scrollToBottom();
+}
+
+function finalizeDMMessage(el, rawText) {
+  el.style.whiteSpace = '';
+  el.innerHTML = formatDMText(rawText);
+  // Make clickable options
+  el.querySelectorAll('.option').forEach(opt => {
+    opt.addEventListener('click', () => {
+      if (!isBusy) sendMessage(opt.dataset.text, true);
+    });
+  });
+  scrollToBottom();
+}
+
+function formatDMText(raw) {
+  const paragraphs = raw.split(/\n\n+/);
+  let options = [];
+  let mainParas = [];
+
+  paragraphs.forEach(para => {
+    // Detect numbered option lists (lines like "1. ..." "2. ..." etc.)
+    const lines = para.split('\n');
+    const isOptionBlock = lines.every(l => /^\d+\.\s/.test(l.trim()) || l.trim() === '');
+    if (isOptionBlock && lines.filter(l => /^\d+\.\s/.test(l.trim())).length >= 2) {
+      options = lines.filter(l => /^\d+\.\s/.test(l.trim()));
+    } else {
+      mainParas.push(para);
+    }
+  });
+
+  let html = mainParas.map(p => {
+    if (!p.trim()) return '';
+    let text = p
+      .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
+      .replace(/\*(.+?)\*/g, '<em>$1</em>')
+      .replace(/\n/g, '<br>');
+    return `<p>${text}</p>`;
+  }).join('');
+
+  if (options.length > 0) {
+    const optHtml = options.map(line => {
+      const m = line.match(/^(\d+)\.\s+(.+)$/);
+      if (!m) return '';
+      return `<div class="option" data-text="${escHtml(line)}">
+        <span class="opt-num">${m[1]}.</span>
+        <span class="opt-text">${m[2]}</span>
+      </div>`;
+    }).join('');
+    html += `<div class="options">${optHtml}</div>`;
+  }
+
+  return html;
+}
+
+function escHtml(s) {
+  return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+}
+
+function scrollToBottom() {
+  const msgs = document.getElementById('messages');
+  msgs.scrollTop = msgs.scrollHeight;
+}
+
+// ── MAIN SEND ─────────────────────────────────────────
+
+async function sendMessage(text, showAsPlayer = true) {
+  if (isBusy || !text.trim()) return;
+
+  setBusy(true);
+
+  if (showAsPlayer) addPlayerMessage(text);
+
+  const userMsg = { role: 'user', content: text };
+  conversationHistory.push(userMsg);
+
+  const dmEl = addDMMessage();
+  let rawText = '';
+
+  try {
+    const resp = await fetch('/api/chat', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        messages: conversationHistory,
+        character: gameState.character,
+        theme: gameState.theme,
+      }),
+    });
+
+    if (!resp.ok) throw new Error(`Server error: ${resp.status}`);
+
+    const reader = resp.body.getReader();
+    const decoder = new TextDecoder();
+    let buf = '';
+    let firstChunk = true;
+
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+
+      buf += decoder.decode(value, { stream: true });
+      const lines = buf.split('\n');
+      buf = lines.pop() ?? '';
+
+      for (const line of lines) {
+        if (!line.startsWith('data: ')) continue;
+        let data;
+        try { data = JSON.parse(line.slice(6)); } catch { continue; }
+
+        if (data.type === 'text') {
+          if (firstChunk) {
+            // Remove typing indicator
+            dmEl.innerHTML = '';
+            firstChunk = false;
+          }
+          rawText += data.chunk;
+          updateDMMessage(dmEl, rawText);
+        } else if (data.type === 'done') {
+          finalizeDMMessage(dmEl, rawText);
+          if (data.statUpdate) applyStatUpdate(data.statUpdate);
+          conversationHistory.push({ role: 'assistant', content: rawText });
+        } else if (data.type === 'error') {
+          dmEl.textContent = `⚠️ Error: ${data.message}`;
+          conversationHistory.pop();
+        }
+      }
+    }
+  } catch (err) {
+    dmEl.textContent = `⚠️ Error: ${err.message}`;
+    conversationHistory.pop();
+  }
+
+  setBusy(false);
+}
+
+function setBusy(busy) {
+  isBusy = busy;
+  document.getElementById('send-btn').disabled = busy;
+  document.getElementById('roll-btn').disabled = busy;
+  document.getElementById('player-input').disabled = busy;
+  document.querySelectorAll('.quick-btn').forEach(b => b.disabled = busy);
+}
+
+// ── EVENT LISTENERS ───────────────────────────────────
+
+document.getElementById('send-btn').addEventListener('click', () => {
+  const input = document.getElementById('player-input');
+  const text = input.value.trim();
+  if (text) {
+    input.value = '';
+    input.style.height = '';
+    sendMessage(text, true);
+  }
+});
+
+document.getElementById('player-input').addEventListener('keydown', e => {
+  if (e.key === 'Enter' && !e.shiftKey) {
+    e.preventDefault();
+    document.getElementById('send-btn').click();
+  }
+});
+
+document.getElementById('player-input').addEventListener('input', function() {
+  this.style.height = 'auto';
+  this.style.height = Math.min(this.scrollHeight, 120) + 'px';
+});
+
+document.getElementById('quick-actions').addEventListener('click', e => {
+  const btn = e.target.closest('.quick-btn');
+  if (btn && !isBusy) sendMessage(btn.dataset.action, true);
+});
+
+document.getElementById('new-game-btn').addEventListener('click', () => {
+  if (!confirm('Start a new game? Your current adventure will be lost.')) return;
+  document.getElementById('game-screen').classList.remove('visible');
+  document.getElementById('creation-screen').style.display = '';
+  document.getElementById('messages').innerHTML = '';
+  selectedRace = null;
+  selectedClass = null;
+  gameState = null;
+  conversationHistory = [];
+  isBusy = false;
+  document.querySelectorAll('.sel-card').forEach(c => c.classList.remove('selected'));
+  checkBeginReady();
+});
+
+// ── INIT ──────────────────────────────────────────────
+
+buildCreationScreen();
+</script>
+</body>
+</html>