← cd /blog

Article

Building a D&D Dungeon Master in Claude Code That Looks Up Every Number

·
buildstools

Selena cast Mage Armor and jumped 30 feet from a third-floor ledge. The Dungeon Master was a Claude Code skill that looks up every number, saves the game to JSON and decides every outcome before the roll.

The skill is one Markdown file and a lot of JSON

~/.claude/skills/dnd-dm/SKILL.md sits on top of over 300 JSON files. 302 cover the setting, a Japanese folklore-inspired 5e island. The skill has no app or server. Claude loads it on "let's play D&D" and reads and writes the JSON with its file tools. Dice and rules lookups run through Bash. Session 1 had two players rolling physical dice, and Pip was my character.

Count each category of extracted data against the source book. The first audit found 11 companion spirit types where the book has 7, two locations that do not exist, and three adventures to rewrite from source. The second audit counted ten categories, including spells, items, races, subclasses and feats, and all ten matched.

Never let the model make up a number

SKILL.md says, in bold: "NEVER use training data for mechanical rulings. NEVER guess stats, DCs, damage, or spell effects." Claude owns the story. Mechanics come from, in order:

  1. The local ruleset's index.json and the file it points to.
  2. The 5e SRD API.
  3. The players: "I can't find the official rules for [X]. Does someone have the book?"

Anything not on disk is one call away:

curl -sL "https://www.dnd5eapi.co/api/spells/mage-armor"
curl -sL "https://www.dnd5eapi.co/api/monsters/goblin"

Note: Keep the -L. /api/spells/mage-armor answers 301 with a redirect to /api/2014/spells/mage-armor. A monster's armor_class is an array of objects, so the AC is armor_class[0].value.

Keep the game in three JSON files

Each campaign folder holds campaign.json (scene, world flags, quest log), session-log.json, and one sheet per character in players/. Save after every combat round, scene change, rest, level up and big inventory change. Save every ten or so exchanges anyway. The last save of campaign.json in Session 1:

{
  // ...
  "dice_mode": "manual",
  // ...
  "current_scene": {
    "location": "yatamon-fire-snake-alley",
    // ...
    "time": "night",
    "description": "Selena is unconscious (0 HP, stable) on a commandeered noodle cart in Fire Snake Alley. ...",
    // ...
  },
  // ...
  "world_flags": {
    "cops_pursuing": true,
    // ...
    "magic_hat_found": true,
    // ...
  },
  // ...
}

Each quest in quest_log records who gave it.

Log every significant beat, specific and brief. If campaign.json breaks, the skill rebuilds the scene, quests and flags from the log. In Session 1 the log was also more current. Its last entry has the party in a subway tunnel, one beat after the last campaign.json save. The entry for the guards:

{
  "type": "scene",
  "description": "Two city guards confronted Pip. Deception check 19+4=23 vs DC 14 — Pip claimed to be her physician, the mushrooms were medicinal, the cat is licensed. Guards let them go. One wrote something in a notebook."
}

House rules for one character live on that character's sheet. Selena's:

"custom_traits": {
  "chaotic_luck": "Once per session DM secretly rolls d6: 1-2 = next roll goes badly, 5-6 = goes spectacularly, 3-4 = normal. Player never knows when it triggers.",
  // ...
  "excessive_packer": "Speed permanently 20 ft. Double carrying capacity. Magic Hat negates speed penalty when worn.",
  "clumsy": "DEX 9. Disadvantage on Acrobatics. On critical DEX failures, something breaks or spills.",
  "good_in_a_crisis": "At or below 25% HP or in dire party situations: advantage on all ability checks. Represented by Heroic boon (pre-assigned)."
}

A trait like chaotic_luck needs a procedure for the secret roll. The skill's house rules, in 01-house-rules.md, give one: roll at session start, note the result privately, apply it when the story allows, and never announce the mechanic.

Selena's companion spirit, Sebastian, speaks in the third person. So does Pip's bat, Squib. Pip's sheet notes: "Every scene with both companions is a disaster." Give each spirit a distinct verbal tic, and never let both speak in one narration block.

Decide every outcome before the roll

From SKILL.md: "Before every non-trivial roll: pre-calculate all 4 outcome tiers (Nat 1 / Miss / Hit / Nat 20) so the response is instant after the player rolls." With physical dice, the four lines are on screen before the die is thrown. The skill's example for a Strength check:

> Kick the plank out? Strength check — **roll d20 + 3** (DC 12).
> - **Nat 1**: Your foot slips on the wet wood...
> - **2-8**: The plank holds firm, goblin grins...
> - **9-19**: Plank snaps free, goblin scrambles for balance...
> - **Nat 20**: Plank explodes — goblin pinwheels into the mist!

The bands are die faces with the modifier already counted. At +3 against DC 12, a 9 succeeds.

Damage is rolled before the tiers are shown, normal and crit in one Bash call. The skill's longsword example:

echo "Normal:$(($(jot -r 1 1 8)+3)) Crit:$(($(jot -r 2 1 8 | paste -sd+ - | bc)+3))"

In auto mode the d20 joins the same call:

ATK=$(jot -r 1 1 20) && NDMG=$(($(jot -r 1 1 8)+3)) && CDMG=$(($(jot -r 2 1 8 | paste -sd+ - | bc)+3)) && echo "Atk:$ATK Dmg:$NDMG Crit:$CDMG"

Monster turns batch the same way: every roll in one call, then one block of narration.

Note: jot is the macOS route. Where it is missing, the skill falls back to python3 -c "import random; print(random.randint(1, 20))".

Fail forward on checks, never on damage

A failed check adds a complication instead of ending the scene: success with a cost, partial success, or a new problem. Damage is different. State the consequence before the roll, then keep it: "No softening after a bad roll."

Selena's jump called for a DEX save against DC 12. Her save was a 5. She missed the awning and took 15 bludgeoning from 3d6. Her maximum is 8 HP, and the fall left her at 0 and on death saves.

Pip tried Medicine to stabilise her and rolled a natural 1. The log says he "caused 1 damage to Selena". At 0 HP, any damage counts as a failed death save. A second Medicine check, with advantage, stabilised her.

Voice what is on screen, in the background

With ELEVENLABS_API_KEY set, the DM pipes narration to a script and keeps playing:

echo "The ancient door groans open, revealing a chamber of blue light." | narrate.sh &

The rules require the &: "don't block the game". Dice, HP changes, outcome tiers and prompts to the players are never voiced. George, a warm British storyteller, is the default voice. ELEVENLABS_VOICE=female switches to Sarah.

The script never stops the game. It exits without a key, and plays the file only when ElevenLabs sent audio rather than an error:

if [ -z "${ELEVENLABS_API_KEY:-}" ]; then exit 0; fi
# ...
if [ -s "$TMPFILE" ] && file "$TMPFILE" | grep -q "Audio\|MPEG"; then
  afplay "$TMPFILE" 2>/dev/null || true
fi

An earlier version voiced a summary instead and cut "Previously on..." recaps off partway. Voice the text on screen word for word.