Files
letta-code-sdk/examples/dungeon-master/dm.ts
Cameron Pfiffer de682b0e26 fix: update all examples to use stream() instead of receive()
- dungeon-master, economics-seminar, research-team all updated
- Also improved tool output display in dungeon-master

🤖 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>
2026-01-27 16:37:00 -08:00

418 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Dungeon Master Agent
*
* A persistent DM that creates its own game system and runs campaigns.
*/
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createSession, resumeSession, type Session } from '../../src/index.js';
import { GameState, DEFAULT_CONFIG, PATHS, CAMPAIGN_FILES } from './types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const STATE_FILE = join(__dirname, PATHS.stateFile);
const RULEBOOK_FILE = join(__dirname, PATHS.rulebook);
const CAMPAIGNS_DIR = join(__dirname, PATHS.campaignsDir);
// ANSI colors
const COLORS = {
dm: '\x1b[35m', // Magenta for DM
player: '\x1b[36m', // Cyan for player
system: '\x1b[90m', // Gray for system messages
reset: '\x1b[0m',
};
/**
* Load game state from disk
*/
export async function loadState(): Promise<GameState> {
if (existsSync(STATE_FILE)) {
const data = await readFile(STATE_FILE, 'utf-8');
return JSON.parse(data);
}
return {
dmAgentId: null,
activeCampaign: null,
campaigns: [],
};
}
/**
* Save game state to disk
*/
export async function saveState(state: GameState): Promise<void> {
await writeFile(STATE_FILE, JSON.stringify(state, null, 2));
}
/**
* Get campaign directory path
*/
function getCampaignDir(campaignName: string): string {
return join(CAMPAIGNS_DIR, campaignName);
}
/**
* Get campaign file path
*/
function getCampaignFile(campaignName: string, file: keyof typeof CAMPAIGN_FILES): string {
return join(getCampaignDir(campaignName), CAMPAIGN_FILES[file]);
}
/**
* Check if rulebook exists
*/
export function hasRulebook(): boolean {
return existsSync(RULEBOOK_FILE);
}
/**
* Read the rulebook
*/
export async function readRulebook(): Promise<string | null> {
if (!hasRulebook()) return null;
return readFile(RULEBOOK_FILE, 'utf-8');
}
/**
* List all campaigns
*/
export async function listCampaigns(): Promise<string[]> {
if (!existsSync(CAMPAIGNS_DIR)) return [];
const entries = await readdir(CAMPAIGNS_DIR, { withFileTypes: true });
return entries.filter(e => e.isDirectory()).map(e => e.name);
}
/**
* Create or resume the DM agent
*/
export async function createDM(state: GameState): Promise<Session> {
if (state.dmAgentId) {
// Resume existing DM
return resumeSession(state.dmAgentId, {
model: DEFAULT_CONFIG.model,
allowedTools: ['Read', 'Write'],
permissionMode: 'bypassPermissions',
});
}
// Create new DM
const session = await createSession({
model: DEFAULT_CONFIG.model,
systemPrompt: `You are a Dungeon Master - a creative storyteller and game designer who runs tabletop RPG campaigns.
## Your Role
- Design and run engaging tabletop RPG experiences
- Create your own game system with rules you write in rulebook.md
- Manage persistent campaign worlds that remember everything
- Collaborate with players on tone, style, and what kind of experience they want
## Your Style
- Adapt to what the player wants (serious, funny, dark, lighthearted)
- Be descriptive and immersive in narration
- Give players meaningful choices with real consequences
- Keep things moving - don't get bogged down in rules
## File Management
You have access to Read and Write tools. Use them to:
- Write and update your rulebook (rulebook.md)
- Manage campaign files in campaigns/{name}/:
- world.md - Setting, locations, lore
- player.md - Character sheet, backstory, inventory
- npcs.md - NPCs met, relationships
- quests.md - Active/completed quests
- session-log.md - What happened each session
- consequences.md - Pending events from past actions
## Important
- Always update campaign files after significant events
- Reference your rulebook when resolving actions
- Make the world feel alive and reactive to player choices`,
memory: [
{
label: 'campaign-state',
value: `# Current Campaign State
## Active Campaign
None - waiting to start or load a campaign
## Recent Events
(none yet)
## Pending Consequences
(none yet)`,
description: 'Track the current campaign state and important recent events',
},
{
label: 'player-preferences',
value: `# Player Preferences
## Tone
(not yet established - ask the player)
## Play Style
(not yet established)
## Boundaries
(ask if there are topics to avoid)`,
description: 'Remember what the player enjoys and any boundaries',
},
],
allowedTools: ['Read', 'Write'],
permissionMode: 'bypassPermissions',
});
return session;
}
/**
* Stream output with color
*/
function createStreamPrinter(): (text: string) => void {
return (text: string) => {
process.stdout.write(`${COLORS.dm}${text}${COLORS.reset}`);
};
}
/**
* Send a message to the DM and get response
*/
export async function chat(
session: Session,
message: string,
onOutput?: (text: string) => void
): Promise<string> {
await session.send(message);
let response = '';
const printer = onOutput || createStreamPrinter();
let lastToolName = '';
for await (const msg of session.stream()) {
if (msg.type === 'assistant') {
response += msg.content;
printer(msg.content);
lastToolName = '';
} else if (msg.type === 'tool_call' && 'toolName' in msg) {
if (msg.toolName !== lastToolName) {
console.log(`\n${COLORS.system}[${msg.toolName}]${COLORS.reset}`);
lastToolName = msg.toolName;
}
}
}
return response;
}
/**
* Initialize a new DM (create rulebook)
*/
export async function initializeDM(session: Session, state: GameState): Promise<void> {
console.log(`\n${COLORS.system}The DM is creating its game system...${COLORS.reset}\n`);
const prompt = `You're starting fresh as a Dungeon Master. Your first task is to create your game system.
Write a rulebook.md file that contains your custom tabletop RPG rules. Include:
1. **Core Mechanic** - How do players resolve actions? (dice, cards, narrative, etc.)
2. **Character Stats** - What defines a character mechanically?
3. **Combat** - How does fighting work?
4. **Skills/Abilities** - What can characters do?
5. **Progression** - How do characters grow?
6. **Health/Death** - How does damage and dying work?
Keep it simple but complete enough to run a game. You can always refine it later.
Use the Write tool to create rulebook.md now.`;
await chat(session, prompt, createStreamPrinter());
// Save the DM agent ID
if (session.agentId) {
state.dmAgentId = session.agentId;
await saveState(state);
}
console.log(`\n\n${COLORS.system}Rulebook created! The DM is ready.${COLORS.reset}`);
console.log(`${COLORS.system}[DM Agent: ${session.agentId}]${COLORS.reset}`);
console.log(`${COLORS.system}[→ https://app.letta.com/agents/${session.agentId}]${COLORS.reset}\n`);
}
/**
* Start a new campaign
*/
export async function startNewCampaign(
session: Session,
state: GameState,
campaignName: string
): Promise<void> {
// Create campaign directory
const campaignDir = getCampaignDir(campaignName);
await mkdir(campaignDir, { recursive: true });
// Update state
state.activeCampaign = campaignName;
if (!state.campaigns.includes(campaignName)) {
state.campaigns.push(campaignName);
}
await saveState(state);
console.log(`\n${COLORS.system}Starting new campaign: ${campaignName}${COLORS.reset}\n`);
const prompt = `We're starting a new campaign called "${campaignName}".
The campaign files are in: campaigns/${campaignName}/
First, let's create this world together. Ask me:
1. What kind of setting/world interests me?
2. What tone am I looking for? (serious, comedic, dark, heroic, etc.)
3. Any topics I'd like to avoid?
Then ask about my character concept - I'll describe who I want to play and you'll help fill in the mechanical details based on your rulebook.
Start by greeting me and asking these questions.`;
await chat(session, prompt, createStreamPrinter());
console.log('\n');
}
/**
* Resume an existing campaign
*/
export async function resumeCampaign(
session: Session,
state: GameState,
campaignName: string
): Promise<void> {
state.activeCampaign = campaignName;
await saveState(state);
console.log(`\n${COLORS.system}Resuming campaign: ${campaignName}${COLORS.reset}\n`);
const prompt = `We're resuming the campaign "${campaignName}".
Please:
1. Read the campaign files in campaigns/${campaignName}/ to refresh your memory
2. Read your rulebook.md if needed
3. Give me a brief recap of where we left off
4. Set the scene for our next moment of play
Use the Read tool to load the campaign state, then continue our adventure.`;
await chat(session, prompt, createStreamPrinter());
console.log('\n');
}
/**
* Main gameplay loop
*/
export async function playSession(session: Session): Promise<void> {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const askQuestion = (prompt: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
resolve(answer);
});
});
};
console.log(`${COLORS.system}(Type 'quit' to end session, 'save' to save progress)${COLORS.reset}\n`);
while (true) {
const input = await askQuestion(`${COLORS.player}> ${COLORS.reset}`);
if (input.toLowerCase() === 'quit' || input.toLowerCase() === 'exit') {
// Ask DM to save state
console.log(`\n${COLORS.system}Ending session...${COLORS.reset}\n`);
await chat(session, `The player is ending the session. Please:
1. Update the session-log.md with a summary of what happened this session
2. Update any other campaign files that need changes (player.md, npcs.md, quests.md, consequences.md)
3. Give a brief farewell that hints at what might come next
Use the Write tool to save everything.`, createStreamPrinter());
console.log('\n');
break;
}
if (input.toLowerCase() === 'save') {
console.log(`\n${COLORS.system}Saving progress...${COLORS.reset}\n`);
await chat(session, `Please save the current game state:
1. Update session-log.md with recent events
2. Update player.md with any changes to the character
3. Update any other files that need it
Use the Write tool to save.`, createStreamPrinter());
console.log('\n');
continue;
}
if (!input.trim()) continue;
// Send player input to DM
console.log('');
await chat(session, input, createStreamPrinter());
console.log('\n');
}
rl.close();
}
/**
* Show game status
*/
export async function showStatus(state: GameState): Promise<void> {
console.log('\n🎲 Dungeon Master Status\n');
console.log(`DM Agent: ${state.dmAgentId || '(not created)'}`);
if (state.dmAgentId) {
console.log(` → https://app.letta.com/agents/${state.dmAgentId}`);
}
console.log(`\nRulebook: ${hasRulebook() ? '✓ Created' : '✗ Not created'}`);
console.log(`\nActive Campaign: ${state.activeCampaign || '(none)'}`);
const campaigns = await listCampaigns();
console.log(`\nCampaigns (${campaigns.length}):`);
if (campaigns.length === 0) {
console.log(' (no campaigns yet)');
} else {
for (const name of campaigns) {
const marker = name === state.activeCampaign ? ' ← active' : '';
console.log(` - ${name}${marker}`);
}
}
console.log('');
}
/**
* Reset everything
*/
export async function resetAll(): Promise<void> {
const fs = await import('node:fs/promises');
if (existsSync(STATE_FILE)) {
await fs.unlink(STATE_FILE);
}
if (existsSync(RULEBOOK_FILE)) {
await fs.unlink(RULEBOOK_FILE);
}
if (existsSync(CAMPAIGNS_DIR)) {
await fs.rm(CAMPAIGNS_DIR, { recursive: true });
await mkdir(CAMPAIGNS_DIR);
}
console.log('\n🗑 Reset complete. DM and all campaigns deleted.\n');
}