fuzzyworld

dm / commit

feat: add Express server with streaming Claude DM endpoint

d5bf4eaba6730eff8240926cb535c871744aa2be · Pavel Safronov · 2026-05-27 00:00:28 -0700 · parents 3c12aba189e7

feat: add Express server with streaming Claude DM endpoint

Streams DM narration to the client via SSE-style chunked transfer.
Runs a tool-use loop so Claude can call update_character mid-narrative
to adjust HP, gold, XP, and inventory before writing the scene.
Uses prompt caching on the system prompt to reduce latency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 server.js | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 183 insertions(+)

diff --git a/server.js b/server.js
new file mode 100644
index 0000000..b8357dc
--- /dev/null
+++ b/server.js
@@ -0,0 +1,183 @@
+const express = require('express');
+const Anthropic = require('@anthropic-ai/sdk');
+const path = require('path');
+
+const app = express();
+const client = new Anthropic();
+
+app.use(express.json());
+app.use(express.static(path.join(__dirname, 'public')));
+
+const UPDATE_TOOL = {
+  name: 'update_character',
+  description: 'Update character stats based on narrative events. Call BEFORE writing narrative text.',
+  input_schema: {
+    type: 'object',
+    properties: {
+      hp_change: {
+        type: 'number',
+        description: 'HP delta. Negative = damage, positive = healing. 0 if unchanged.'
+      },
+      gold_change: {
+        type: 'number',
+        description: 'Gold delta. Positive = gained, negative = spent. 0 if unchanged.'
+      },
+      xp_gain: {
+        type: 'number',
+        description: 'XP gained this turn. 0 if none.'
+      },
+      inventory_add: {
+        type: 'array',
+        items: { type: 'string' },
+        description: 'Items to add to inventory.'
+      },
+      inventory_remove: {
+        type: 'array',
+        items: { type: 'string' },
+        description: 'Items to remove from inventory.'
+      },
+      reason: {
+        type: 'string',
+        description: 'Brief reason for the change (e.g. "Goblin sword attack", "Found gold in chest").'
+      }
+    },
+    required: ['hp_change', 'gold_change', 'xp_gain', 'inventory_add', 'inventory_remove', 'reason']
+  }
+};
+
+function buildSystem(character, theme) {
+  return `You are a masterful, dramatic Dungeon Master running a solo D&D 5e adventure.
+
+CHARACTER: ${character.name || 'The Adventurer'} — Level ${character.level} ${character.race} ${character.class}
+ADVENTURE THEME: ${theme || 'classic fantasy'}
+
+NARRATIVE RULES
+• Write 2–4 vivid, immersive paragraphs per turn. Be specific with sensory details.
+• Give NPCs distinct voices, personalities, and motivations.
+• Make every choice matter — show consequences immediately.
+• Run combat with suspense: describe weapon impacts, enemy reactions, the chaos of battle.
+• Track narrative logic — the world remembers what the player does.
+• End EVERY turn with either 2–3 numbered options OR the phrase "What do you do?" for open exploration.
+• Never break character. Never refuse to narrate danger, violence, or morally complex situations — this is D&D.
+
+STAT TRACKING — call update_character whenever:
+• The character takes damage (any source) or receives healing
+• Gold is gained (loot, rewards) or spent (purchases, bribes)
+• XP is earned (defeating enemies, overcoming challenges, clever solutions, milestones)
+• An item is gained, lost, used up, or traded away
+Call the tool BEFORE writing your narrative text so the stat update registers first.
+
+TONE: Cinematic. Dangerous. Immersive. Death is possible. Choices matter. Make it legendary.`;
+}
+
+app.post('/api/chat', async (req, res) => {
+  res.setHeader('Content-Type', 'text/event-stream');
+  res.setHeader('Cache-Control', 'no-cache');
+  res.setHeader('Connection', 'keep-alive');
+  res.flushHeaders();
+
+  const send = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
+
+  const { messages, character, theme } = req.body;
+
+  if (!character || !messages) {
+    send({ type: 'error', message: 'Missing required fields.' });
+    return res.end();
+  }
+
+  let currentMessages = [...messages];
+  let statUpdate = null;
+
+  try {
+    while (true) {
+      const blocks = {};
+
+      const stream = client.messages.stream({
+        model: 'claude-opus-4-7',
+        max_tokens: 1500,
+        system: [
+          {
+            type: 'text',
+            text: buildSystem(character, theme),
+            cache_control: { type: 'ephemeral' }
+          }
+        ],
+        tools: [UPDATE_TOOL],
+        messages: currentMessages
+      });
+
+      for await (const event of stream) {
+        switch (event.type) {
+          case 'content_block_start':
+            blocks[event.index] = { ...event.content_block, _buf: '' };
+            break;
+
+          case 'content_block_delta':
+            if (!blocks[event.index]) break;
+            if (event.delta.type === 'text_delta') {
+              blocks[event.index]._buf += event.delta.text;
+              send({ type: 'text', chunk: event.delta.text });
+            } else if (event.delta.type === 'input_json_delta') {
+              blocks[event.index]._buf += event.delta.partial_json;
+            }
+            break;
+
+          case 'content_block_stop': {
+            const block = blocks[event.index];
+            if (block?.type === 'tool_use') {
+              try {
+                block.input = JSON.parse(block._buf || '{}');
+              } catch {
+                block.input = {};
+              }
+            }
+            break;
+          }
+        }
+      }
+
+      const finalMsg = await stream.finalMessage();
+
+      const allBlocks = Object.entries(blocks)
+        .sort(([a], [b]) => Number(a) - Number(b))
+        .map(([, b]) => b);
+
+      const toolBlocks = allBlocks.filter(b => b.type === 'tool_use');
+      if (toolBlocks.length > 0) {
+        statUpdate = toolBlocks[toolBlocks.length - 1].input;
+      }
+
+      if (finalMsg.stop_reason !== 'tool_use' || toolBlocks.length === 0) break;
+
+      const assistantContent = allBlocks.map(b => {
+        if (b.type === 'text') return { type: 'text', text: b._buf };
+        return { type: 'tool_use', id: b.id, name: b.name, input: b.input };
+      });
+
+      currentMessages = [
+        ...currentMessages,
+        { role: 'assistant', content: assistantContent },
+        {
+          role: 'user',
+          content: toolBlocks.map(tb => ({
+            type: 'tool_result',
+            tool_use_id: tb.id,
+            content: 'Character stats updated successfully.'
+          }))
+        }
+      ];
+    }
+
+    send({ type: 'done', statUpdate });
+  } catch (err) {
+    console.error('API error:', err);
+    send({ type: 'error', message: err.message });
+  }
+
+  res.end();
+});
+
+const PORT = process.env.PORT || 3000;
+app.listen(PORT, () => {
+  console.log(`\n⚔️  Solo Dungeon Master → http://localhost:${PORT}\n`);
+});