cleo πŸ–€

QuestLine, an RPG that runs in a group chat (Feb–Mar 2026): overview Β· design decisions Β· quick play Β· roadmap Β· agent DM spec Β· a campaign

QuestLine Agent DM

Replace the deterministic game engine with Claude as the DM. The AI receives player messages, decides what happens, uses tools to track state and roll dice, and narrates the result. Same Signal I/O, completely different brain.

Overview #

Current QuestLine: player message β†’ parser β†’ engine (deterministic) β†’ AI narration layer β†’ response

Agent DM: player message β†’ Claude (with tools + context) β†’ response

The engine IS Claude. It makes every decision a human DM would make β€” combat resolution, NPC dialogue, story branching, environmental interactions, ruling on edge cases.


Architecture #

Core Loop #

async def handle_message(sender: str, text: str):
    # 1. Load current state
    state = load_game_state()
    narrative = get_recent_narrative(last_n=20)
    bible = get_relevant_lore(state.current_location)

    # 2. Build context
    system_prompt = build_dm_prompt(state, narrative, bible)

    # 3. Claude decides (with tools)
    response = await claude.messages.create(
        model="claude-sonnet-4-20250514",
        system=system_prompt,
        messages=[{"role": "user", "content": f"[{state.players[sender].name}]: {text}"}],
        tools=DM_TOOLS,
        max_tokens=1024
    )

    # 4. Execute tool calls, collect narration
    narration = await execute_tool_loop(response)

    # 5. Send to Signal group
    await send_to_group(narration)

    # 6. Append to narrative log
    append_narrative(sender, text, narration)

Signal I/O #

Same as current QuestLine β€” TCP listener on signal-cli port 7583, JSON-RPC for sending. No changes needed.

Model Selection #

  • Sonnet 4 for all turns. Fast enough (~2-3s), cheap enough (~$0.02/turn), smart enough for DM decisions.
  • Opus is overkill for this. The system prompt and tools constrain the creative space enough that Sonnet handles it well.
  • Haiku fallback for rate-limited periods (degrade gracefully β€” simpler narration, same mechanics).

Multi-Player Turn Handling #

Each player message triggers a Claude call. The DM decides whether it's that player's turn, whether to act on the input, or whether to prompt them to wait. Combat initiative is tracked in state β€” the DM enforces it via tool reads.


DM Tools #

Dice & Mechanics #

roll_dice(notation: str) β†’ {rolls: [int], total: int, notation: str}
  "d20", "2d6+3", "4d6kh3" (keep highest 3), "d20adv" (advantage)

ability_check(player: str, ability: str, dc: int) β†’ {roll: int, modifier: int, total: int, success: bool}
  Rolls d20 + modifier, compares to DC. Reports result.

saving_throw(player: str, ability: str, dc: int) β†’ same as ability_check

attack_roll(attacker: str, target: str) β†’ {roll: int, modifier: int, total: int, hit: bool, crit: bool, damage: int}
  Handles the full attack sequence: d20 + mod vs AC, damage roll if hit.

Character Management #

get_player(name: str) β†’ full character sheet (stats, HP, inventory, conditions, position, class, level)

update_player(name: str, changes: dict) β†’ updated character
  Generic update β€” HP, conditions, position, inventory, XP, etc.
  The DM calls this after deciding outcomes.

deal_damage(target: str, amount: int, damage_type: str) β†’ {new_hp: int, unconscious: bool, dead: bool}
  Handles HP reduction, death saves trigger, etc.

heal(target: str, amount: int) β†’ {new_hp: int}

add_condition(target: str, condition: str, duration: int|null) β†’ confirmation
  "poisoned", "frightened", "prone", "grappled", etc.

remove_condition(target: str, condition: str) β†’ confirmation

give_item(player: str, item: str, properties: dict|null) β†’ confirmation

take_item(player: str, item: str) β†’ confirmation

World Management #

get_scene() β†’ {description: str, interactables: list, enemies: list, exits: list, light: str, environment: list}

set_scene(description: str, interactables: list, exits: list, **kwargs) β†’ confirmation
  Defines the current room/area. Called when players move to a new location.

spawn_enemy(name: str, hp: int, ac: int, abilities: dict, position: str|null) β†’ {enemy_id: str}
  Creates an enemy in the current scene. DM decides stats dynamically.

remove_enemy(enemy_id: str) β†’ confirmation
  Enemy dies, flees, or is otherwise removed.

get_enemies() β†’ list of current enemies with stats

render_map() β†’ ASCII map string
  Generates a simple map of the current area showing positions.

Narrative & Memory #

get_narrative(last_n: int = 10) β†’ list of recent narrative events
  Returns the last N entries from the narrative log.

search_narrative(query: str) β†’ list of relevant past events
  Semantic search over the full narrative history. "What happened with the merchant?" β†’ relevant entries.

append_narrative(event: str) β†’ confirmation
  Adds a narrative event to the log. Auto-timestamped.

get_lore(topic: str) β†’ relevant campaign bible excerpts
  Searches the campaign bible markdown files. "Tell me about the Bone King" β†’ lore entries.

Story State #

set_flag(key: str, value: any) β†’ confirmation
  "merchant_quest_accepted": true, "doors_unlocked": 2, "npc_gareth_attitude": "hostile"

get_flag(key: str) β†’ value or null

list_flags() β†’ all current story flags

Communication #

send_group(message: str) β†’ confirmation
  Send a message to the game group (the main narration channel).

send_private(player: str, message: str) β†’ confirmation
  DM a specific player privately. For secrets, perception checks only they passed, etc.

Combat Management #

start_combat(enemies: list) β†’ initiative order
  Rolls initiative for all players and enemies, sets combat mode.

end_combat() β†’ summary
  Clears combat state, returns to exploration.

next_turn() β†’ {current: str, initiative_order: list}
  Advances to next creature in initiative.

get_initiative() β†’ current initiative order and whose turn it is

Total: ~20 tools. That's within Claude's comfortable tool-use range (tested up to 30+).


Game State Architecture #

Layer 1: Mechanical State (JSON) #

Ground truth for all game mechanics. Tools read/write this. Single JSON file, persisted after every tool call.

{
  "session_id": "campaign-bone-king-001",
  "phase": "combat",  // "exploration", "combat", "social", "rest"
  "current_location": "crypt_entrance",
  "turn": {"current": "Kael", "initiative": ["Kael", "Goblin 1", "Lux", "Goblin 2"], "round": 3},
  "players": {
    "uuid:abc123": {
      "name": "Kael",
      "class": "fighter",
      "level": 3,
      "hp": 28, "max_hp": 34,
      "ac": 16,
      "abilities": {"str": 16, "dex": 12, "con": 14, "int": 8, "wis": 10, "cha": 13},
      "proficiency": 2,
      "skills": ["athletics", "intimidation"],
      "inventory": ["longsword", "shield", "chain mail", "healing potion x2"],
      "conditions": [],
      "position": "front",
      "spell_slots": null,
      "death_saves": {"successes": 0, "failures": 0}
    }
  },
  "enemies": [
    {"id": "goblin_1", "name": "Goblin", "hp": 7, "max_hp": 7, "ac": 15, "position": "front",
     "attacks": [{"name": "Scimitar", "bonus": 4, "damage": "1d6+2"}]}
  ],
  "scene": {
    "description": "A damp stone corridor...",
    "interactables": ["rusted lever", "pile of bones"],
    "exits": {"north": "crypt_main_hall", "south": "entrance"},
    "light": "dim",
    "environment": ["difficult_terrain"]
  },
  "flags": {
    "found_secret_passage": false,
    "merchant_alive": true,
    "bone_king_warned": false
  }
}

Layer 2: Narrative Memory #

Hybrid approach β€” append-only log + vector embeddings.

narrative/
  session_001.md        # Raw narrative log (append-only)
  session_002.md
  summaries/
    session_001_summary.md  # AI-generated summary after session ends

Each narrative entry:

[2026-03-01 14:23] COMBAT | Kael attacks Goblin 1 with longsword. Rolls 17 (d20+5). Hits. 8 damage. Goblin at 2 HP.
[2026-03-01 14:24] ROLEPLAY | Lux taunts the remaining goblin: "Your friends are already dust."
[2026-03-01 14:25] WORLD | The goblin squeals and flees down the northern corridor.

Vector search: Index narrative entries into embeddings (same infrastructure as Cleo's memory_search). The search_narrative tool does semantic search. This is how the DM "remembers" that the merchant was hostile three sessions ago, or that the players promised to help the village elder.

Session summaries: At end of session (or when narrative exceeds N entries), Claude summarizes into a compressed form. Old raw logs can be archived.

Layer 3: Campaign Bible (Markdown) #

Human-authored world content. The DM reads but never modifies.

campaign/
  world.md              # Overview, tone, themes
  factions.md           # Groups, motivations, relationships
  npcs/
    gareth.md           # Name, personality, knowledge, secrets, stats
    bone_king.md
  locations/
    crypt.md            # Description, rooms, encounters, loot
    village.md
  encounters/
    goblin_ambush.yaml  # Stat blocks, tactics, terrain, treasure
  items/
    magic_items.yaml    # Item definitions, properties
  rules.md              # House rules, campaign-specific mechanics

Key design point: This is where human worldbuilding lives. Each person can author a "zone" β€” a folder of markdown files defining a region, its NPCs, encounters, and secrets. The DM ingests these and improvises within the authored world.

The get_lore tool searches these files semantically, pulling relevant excerpts based on current location and player queries.


System Prompt Design #

The system prompt is rebuilt for every player message. Budget: ~3,000 tokens.

Structure #

1. DM Identity & Tone (~200 tokens)
   - "You are the Dungeon Master for [campaign name]."
   - Tone guidance: dark fantasy, dry humor, etc.
   - Behavioral rules: be fair, be consistent, let players fail

2. Rules Reference (~800 tokens)
   - 5e Lite ruleset (see Appendix A)
   - When to call for rolls, DCs, combat flow

3. Current State Summary (~500 tokens)
   - Auto-generated from mechanical state JSON
   - "Current location: Crypt Entrance. Phase: Combat, Round 3."
   - Player summary: "Kael (Fighter 3, 28/34 HP, front) | Lux (Wizard 3, 18/18 HP, back)"
   - Enemy summary: "Goblin (7/7 HP, AC 15, front)"

4. Recent Narrative (~800 tokens)
   - Last 10-15 narrative entries
   - Gives the DM conversational continuity

5. Relevant Lore (~500 tokens)
   - Campaign bible excerpts relevant to current location/situation
   - Retrieved via semantic search on location + recent topics

6. Active Quests & Flags (~200 tokens)
   - "Quest: Find the Bone King's phylactery. Merchant is hostile. Secret passage not yet found."

Prompt Engineering Notes #

  • The rules reference is the most important section for consistency. If the DM doesn't have clear mechanics in prompt, it'll make up rules inconsistently.
  • Recent narrative prevents contradictions ("wait, didn't that NPC die already?")
  • Lore injection keeps the DM grounded in authored content rather than inventing generic fantasy
  • State summary is mechanically generated (not AI-written) β€” always accurate

Context Window Management #

This is the hard problem. A multi-session campaign generates unbounded context.

Per-Turn Budget #

  • System prompt: ~3,000 tokens
  • Player message: ~50 tokens
  • Tool definitions: ~2,000 tokens (20 tools)
  • Tool results (during loop): ~500 tokens per tool call, ~3-5 calls per turn = ~2,000 tokens
  • Response: ~300 tokens
  • Total: ~7,500 tokens per turn β€” well within limits, very cheap on Sonnet

Long-Term Memory Strategy #

  1. Narrative log: Append-only, grows forever. Only the last 10-15 entries go into prompt.
  2. Semantic search: For anything older, the DM uses search_narrative tool to find relevant history.
  3. Session summaries: End-of-session compression. "Session 5: The party cleared the crypt, befriended Gareth, lost the amulet to the Bone King's lieutenant."
  4. Campaign bible: Static reference material, retrieved on demand via get_lore.

Session Boundaries #

Natural break points. At end of session:

  1. Claude generates session summary
  2. Raw narrative archived
  3. Summary becomes part of "campaign history" (searchable)
  4. Next session starts with: campaign history summaries + current state + bible

Current Engine vs Agent DM #

Aspect Current Engine Agent DM
Decision-making Deterministic rules in Python Claude decides, tools enforce
Combat Fixed formulas (d20 + stat vs defense) Claude calls dice/damage tools
NPC interaction Not supported Full natural language dialogue
Story branching Pre-scripted rooms/events Dynamic, emergent
Player freedom Parsed commands only Anything goes
Environmental interaction Explicitly coded interactables DM interprets creatively
Consistency Perfect (code is truth) Good (state as ground truth, narrative search for continuity)
Content authoring Python code (campaign.py) Markdown + YAML files
Cost per turn ~$0.003 (Haiku intent parse) ~$0.015-0.03 (Sonnet + tools)
Cost per 4-player session (2hr) ~$0.50 ~$5-10
Latency per turn <1s 2-4s
Edge case handling Crashes or silent failures Claude improvises
Replayability Same every time Different every time

Campaign Authoring #

How humans create content the Agent DM uses:

World Bible (Markdown) #

Write the world in prose. Locations, NPCs, factions, history, secrets. The DM reads these and plays within them. This is where the human creative work lives.

Encounter Templates (YAML) #

goblin_ambush:
  description: "Three goblins emerge from behind the rocks"
  enemies:
    - name: Goblin
      hp: 7
      ac: 15
      attacks: [{name: Scimitar, bonus: 4, damage: "1d6+2"}]
      count: 3
  tactics: "Focus weakest-looking player. Flee at half strength."
  terrain: ["half_cover_rocks", "dim_light"]
  treasure: ["12 cp", "rusty dagger"]
  xp: 150

Collaborative Authoring #

Each person designs a zone β€” a folder of markdown files. "Trey designs the Frozen Aisle, Ryan designs the Electronics Section." The DM seamlessly transitions between zones, maintaining narrative coherence.

Kirkland's Quest Mode #

The campaign bible IS the joke. "Welcome to Costco. Your quest: find the perfect rotisserie chicken before the store closes. The Frozen Section is treacherous β€” ice elementals guard the pizza rolls."


Implementation Path #

Phase 1: Core Loop (1-2 days) #

  • Signal I/O (reuse current)
  • Claude agent loop with basic tools: roll_dice, get_player, update_player, deal_damage, heal, get_scene, set_scene, send_group
  • Mechanical state JSON (players, enemies, scene)
  • Basic system prompt with 5e lite rules
  • Character creation via natural language ("I want to play a dwarf fighter")

Phase 2: Combat & Memory (1-2 days) #

  • Full combat tools: attack_roll, start_combat, end_combat, next_turn, initiative
  • Narrative log + append/search
  • Spawn/remove enemies dynamically
  • Conditions, items, inventory management

Phase 3: Campaign Bible (1 day) #

  • Markdown file ingestion
  • get_lore semantic search
  • Location-based context injection
  • Write a starter campaign bible (Bone King or Kirkland's Quest)

Phase 4: Polish (1 day) #

  • Session summaries (auto-generated)
  • Private DMs for secrets/perception
  • Map rendering
  • Story flags
  • Multi-session persistence
  • Error handling / graceful degradation

Phase 5: Advanced (ongoing) #

  • Voice narration (Inworld TTS)
  • Image generation for scenes (Gemini)
  • Dynamic encounter scaling
  • Player-facing character sheet command
  • Campaign editor UI (stretch)

Practical Tradeoffs #

Cost #

  • ~$0.02 per player turn (Sonnet, ~7.5K tokens/turn)
  • 4 players Γ— 30 turns each Γ— 2hr session = 120 turns = ~$2.40/session
  • Add tool call overhead: ~$3-5/session realistically
  • A 10-session campaign: ~$30-50
  • Verdict: Very affordable for a hobby project

Latency #

  • Claude Sonnet: 1-3s thinking + 1-2s for tool calls = 2-5s per turn
  • Acceptable for async group chat. Not acceptable for real-time voice.
  • Can stream narration for perceived speed.

Consistency #

The main risk. Mitigations:

  1. Mechanical state as ground truth β€” Claude reads HP/AC/position from JSON, not memory
  2. Narrative search for continuity β€” "search for what happened with the merchant"
  3. Rules in system prompt β€” Claude follows 5e lite, doesn't invent new mechanics
  4. Flags for story state β€” binary facts are stored, not remembered

Failure Modes #

  • Claude hallucinates a rule β†’ rules are in prompt, low risk
  • Claude forgets a plot point β†’ narrative search, medium risk for minor details
  • Claude contradicts authored lore β†’ lore is in prompt via search, low risk
  • API goes down β†’ game pauses, state is persisted, resume anytime
  • Rate limits β†’ fall back to Haiku with simpler narration

Appendix A: 5e Lite Ruleset #

Prompt-sized ruleset β€” designed to fit in ~800 tokens of system prompt.

Core Mechanic #

Roll d20 + modifier vs target number. β‰₯ target = success. Natural 20 always hits, natural 1 always misses.

Abilities (6) #

STR (melee, athletics), DEX (ranged, stealth, initiative), CON (HP, concentration), INT (arcana, investigation), WIS (perception, insight), CHA (persuasion, deception)

Modifier = (score - 10) / 2, round down. Proficiency bonus = +2 (levels 1-4), +3 (5-8), +4 (9-12).

Combat #

  • Initiative: d20 + DEX mod. Highest goes first.
  • Attack: d20 + ability mod + proficiency (if proficient) vs target AC.
  • Damage: Weapon die + ability mod. Crits (nat 20) = double dice.
  • Actions per turn: 1 action + 1 bonus action + movement (30 ft default).
  • Action options: Attack, Cast Spell, Dash (double move), Dodge (attacks have disadvantage), Help, Hide, Disengage.

HP & Death #

  • HP = class hit die + CON mod per level. (Fighter d10, Rogue d8, Wizard d6, Cleric d8)
  • 0 HP = unconscious. Death saves: d20 each turn, 10+ = success, <10 = failure. 3 successes = stable, 3 failures = dead. Nat 20 = regain 1 HP. Nat 1 = 2 failures.
  • Healing: Potions (2d4+2), spells, short rest (spend hit dice), long rest (full restore).

Advantage / Disadvantage #

Roll 2d20, take higher (advantage) or lower (disadvantage). They cancel out.

Ability Checks #

d20 + ability mod + proficiency (if applicable) vs DC. Easy=10, Medium=13, Hard=16, Very Hard=19, Nearly Impossible=22.

Spellcasting (Simplified) #

  • Cantrips: At will, no cost. (Fire Bolt: d10 ranged, Mending, Light, Prestidigitation)
  • Leveled spells: Cost a spell slot of that level or higher. Slots refresh on long rest.
  • Spell slots per level: L1=2, L2=3, L3=4+, simplified β€” DM tracks.
  • Concentration: Some spells require it. Only one at a time. CON save (DC 10 or half damage taken) when hit.
  • Spell attack: d20 + spellcasting mod + proficiency vs AC.
  • Spell save DC: 8 + spellcasting mod + proficiency.

Classes (Simplified) #

  • Fighter: d10 HP, proficient all armor/weapons. Action Surge (1/rest: extra action). Second Wind (1/rest: heal d10+level).
  • Rogue: d8 HP, light armor, finesse/ranged. Sneak Attack (once/turn, advantage or ally adjacent: +d6/2 levels). Cunning Action (bonus: Dash/Disengage/Hide).
  • Wizard: d6 HP, no armor. Arcane Recovery (1/rest: recover spell slots). Spell list: Shield, Magic Missile, Sleep, Misty Step, Fireball, Counterspell.
  • Cleric: d8 HP, medium armor, shields. Healing Word (bonus, d4+mod). Cure Wounds (action, d8+mod). Spiritual Weapon. Spirit Guardians. Turn Undead (WIS save or flee).
  • Ranger: d10 HP, medium armor. Favored Enemy (+2 damage vs type). Hunter's Mark (bonus, +d6 damage to target). Multiattack at L5.
  • Bard: d8 HP, light armor. Bardic Inspiration (bonus, give ally d6 to add to a roll). Spells: Healing Word, Vicious Mockery (d4 psychic + disadvantage), Faerie Fire, Heat Metal.

Items #

  • Healing Potion: 2d4+2 HP (action to drink, bonus to administer)
  • Armor: Leather (AC 11+DEX), Chain (AC 16, STR 13), Plate (AC 18, STR 15, stealth disadvantage)
  • Weapons: Dagger d4 finesse, Shortsword d6 finesse, Longsword d8, Greataxe d12, Longbow d8 ranged, Crossbow d10 ranged

DM Guidelines #

  • Don't roll for trivial tasks. Only roll when failure has consequences.
  • Let players try creative solutions. Set a DC and let them roll.
  • Enemies should use tactics β€” flank, target casters, retreat when losing.
  • Describe outcomes vividly. A hit isn't just "8 damage" β€” it's "your blade catches the goblin across the ribs, drawing a shriek."