Campaign Structure

Complete reference for campaign JSON file structure, verified directly against renderer/dm-toolkit/dm-toolkit-controller.js, renderer/dm-toolkit/campaign-library.js, and the 30 real bundled campaign files in data/campaigns/.

This page was rewritten from scratch. An earlier version of this page described an invented schema (a campaignId/title/author/difficulty/recommendedLevel top level, a plain-string dmNotes, bare <p> read-aloud text) that does not match what the app reads or what any of the 30 bundled campaigns actually contain. Everything below reflects reality, not aspiration.

File Format

Campaigns are stored as JSON files in:

data/campaigns/NN-slug.json

Each file also needs a matching entry in data/campaigns/index.json (id, filename, and which level-range bucket it belongs to) - that's how the campaign selection grid finds it.

Top-Level Structure

Only name, levelRange, description, and episodes are actually read by the app. Everything else below is optional metadata or narrative source material:

{ "id": "unique-campaign-id", "name": "Campaign Name", "levelRange": { "min": 1, "max": 5 }, "description": "Brief campaign summary (used as fallback intro text)", "theme": "dark-cult", "startingLevel": 1, "targetLevel": 5, "totalEpisodes": 6, "estimatedSessions": 20, "introduction": { /* optional, see below */ }, "bbeg": { /* optional, see below */ }, "dungeons": [ /* optional, see below - real playable locations */ ], "episodes": [] }
Two generations of campaign authoring: some of the 30 bundled campaigns include rich introduction/bbeg narrative source material, others are leaner and skip them entirely. Both are valid - name, levelRange, description, and episodes are the only fields that actually matter to the app.

Top-Level Fields

Field Type Required Description
id string Recommended Unique identifier (kebab-case), matched against data/campaigns/index.json
name string Yes Display name. The app reads campaign.name everywhere it shows the campaign title - there is no title, campaignId, or author field.
levelRange object {min, max} Yes Character level range as numbers, not a string like "1-5"
description string Yes One-paragraph summary; used as the campaign-intro fallback text if episodes[].scenes[] is empty
theme string No, recommended Freeform slug ("dark-cult", "naval-adventure", ...) shown on the selection card - also drives the automatically generated regional map's terrain/danger mix (matched by substring against a keyword table, see Dungeons below), so it's worth setting even though nothing breaks without it.
startingLevel, targetLevel, totalEpisodes, estimatedSessions various No Freeform metadata, shown on the campaign selection card where present
introduction object No {text, startingLocation, startingLocationType, mood, stakes, impactedPopulation} - text can serve as the campaign's opening crawl. Present in roughly half of the bundled campaigns.
bbeg object No Villain reference data (name, title, description, minLevel or requirements.level, location, rewards, requirements) - narrative source material for the climax, not read by the UI directly
dungeons array No, recommended Real, playable locations - see Dungeons below. Every bundled campaign has at least 3.
episodes array Yes Array of episode objects

Dungeons

A top-level array, sibling of episodes - each entry is a real, DM-openable location (fog-of-war room reveal, seeded loot/traps/encounters, an "Start Encounter" hookup into the Initiative Tracker), not just descriptive flavor text. Reference one from anywhere in your prose with {dungeon:id} (see {dungeon:id}) - it renders as a real clickable "Open Dungeon" button.

"dungeons": [ { "id": "blackwater-cove", "name": "Blackwater Cove", "type": "Cave", "difficulty": "medium", "questId": "Find the Boatswain" } ]
Field Type Required Description
id string Yes Kebab-case, unique within the file - this is what {dungeon:id} references
name string Yes Display name - also what gets baked directly into the generated regional map's own danger label (see below), so it's the one name your players will actually see printed on the map
type string Yes One of exactly: Ancient-Crypt, Underground-Ruins, Sewer, Ruin, Cave, Monster-Lair. Determines the generated room layout/theme.
difficulty string Yes One of exactly: easy, medium, hard, deadly. Scale this to the quest's point in your levelRange, not the campaign as a whole - a level-3 side dungeon in a level 1-10 campaign should usually be easy/medium, not deadly.
questId string No, recommended Must exactly match a questTemplates[].name in the same file (case and all) - ties this location to a real quest so accepting/completing it makes narrative sense. A dungeon with no questId can still be referenced by {dungeon:id}, it just won't be pre-linked to anything in the Dungeon list UI.
Writing dungeons into your campaign - the actual workflow:
  1. Pick 2-3 questTemplates[] entries whose description plausibly involves infiltrating, raiding, or exploring a real physical place. Not every quest needs one - a pure negotiation or investigation-only quest usually doesn't.
  2. Add one dungeons[] entry per selected quest, with a questId pointing at it.
  3. Add a {dungeon:id} reference somewhere natural in that quest's own scene text - wherever the party would actually learn about or approach the place. The rendered button is a block-level element with its own vertical margin, so it reads best at a sentence boundary (end of a sentence, or as a trailing appositive) rather than as a sentence's grammatical subject.
  4. Run node scripts/validate-campaign.js your-campaign.json - it checks id uniqueness, that type/difficulty are valid, and that every {dungeon:id} reference actually resolves to a real entry.
Every dungeon appears on a real generated map. Campaign Cookbook generates one regional map per campaign (terrain, named towns, and a marker for every dungeons[] entry), shown in the reading pane's Locations rail view. Your dungeon's name is baked directly into that map's own danger label before the map is ever drawn - so what's printed on the map and what's listed in your dungeons[] array are always the same name, not two different things a DM has to reconcile. The map is generated once per party and persists across sessions; you don't configure anything about it beyond theme (terrain/danger mix) and how many dungeons[] entries you author (at least that many dangers are guaranteed to appear, placed near a town rather than scattered randomly).

Episode Structure

Episodes contain quests and scenes:

{ "id": 1, "episodeNumber": 1, "title": "Episode Title", "description": "Optional flavor text", "questTemplates": [], "scenes": [] }

Episode Fields

Field Type Required Description
id and/or episodeNumber number Yes (at least one) The app's episode lookup checks ep.episodeNumber === n || ep.id === n, so either works - but always set episodeNumber too. Some real bundled files only had id and needed a runtime patch to backfill it.
title string Yes This is the field showEpisodeIntro() actually displays (episode.title). Several real bundled campaigns only had name and showed a literal "undefined" episode title until that was fixed by adding title alongside it - always set title, name alone is not enough.
description string No Optional flavor text. There is no openingCrawl or synopsis field on episodes - those were fictional.
questTemplates array Yes Array of quest template objects
scenes array Yes (can be empty) Array of scene objects. An empty scenes: [] is valid - the app falls back to a generic, more limited quest-driven flow instead of the rich scene-by-scene reading experience.

Quest Template Structure

{ "name": "Quest Name", "id": "quest-name", "type": "investigation", "description": "Quest objective description", "objectives": [ { "id": "find-clues", "description": "Search the trade road for clues", "type": "location", "location": "{nearestBurg}", "progress": { "current": 0, "max": 1 } } ], "rewards": { "gold": 100, "xp": 300, "items": ["Magic Sword"], "reputation": 10 }, "consequences": { /* optional, freeform - narrative reference only */ } }

Quest Template Fields

Field Type Required Description
name string Yes The exact string accept:/complete:/fail: quest actions must match, character for character
id string No, recommended Stable kebab-case key, distinct from name
type string No Freeform label (investigation, combat, rescue-combat, etc.) - not a fixed enum
description string No Quest objective summary
objectives array No Freeform - two shapes both work (see below)
rewards object No xp, gold, items, reputation - see below for the flexible shapes each accepts
consequences object No Freeform narrative reference for what happens on success/fail - not read by the app

Objective Shapes

Real campaign files use one of two interchangeable shapes for each objective; the app's getObjectiveLabel() handles both. The description-based shape is more readable for DMs and recommended for new campaigns:

// Shape A: hand-written description (recommended) { "id": "rescue-villagers", "description": "Rescue trapped villagers (0/5)", "type": "rescue-npc", "progress": { "current": 0, "max": 5 } } // Shape B: terser structured form { "type": "combat", "target": "Shadow Wisp", "count": 3 }

Rewards Shapes

The reward-rendering code accepts multiple shapes for the same field - use whichever is convenient:

"rewards": { "xp": 200, "gold": 100, "items": ["Magic Sword"], // plain strings, OR: [{ "name": "...", "type": "...", "description": "..." }] "reputation": 10 // plain number, OR: { "faction": "...", "value": 10 } }

Scene Structure

{ "sceneId": "ep1-scene1", "title": "Scene Title", "type": "story", "readAloud": "<div class='read-aloud'><p>Descriptive text with {placeholders}...</p></div>", "dmNotes": { "overview": "What's really going on in this scene", "skillChecks": { "perception_dc_13": "Notice the hidden door" } }, "choices": [ { "text": "Choice text", "nextScene": "ep1-scene2", "questActions": ["accept:Quest Name"] } ] }

Scene Fields

Field Type Required Description
sceneId string Yes Unique across the whole file. Convention: ep{episodeNumber}-scene{N} for the main path, ep{episodeNumber}-scene{N}-{descriptor} for branches (e.g. ep1-scene3-exposition).
title string Yes Scene display title
type string No Freeform, cosmetic only - only picks a small icon for a couple of hardcoded cases (combat, investigation); everything else defaults gracefully. Real files use story, social, investigation, combat, exploration, planning, roleplay, puzzle, stealth, chase, diplomacy, resolution, conclusion, horror, ritual, boss-combat, cinematic, tactical, and more - there's no fixed enum.
readAloud HTML Yes Inserted directly into the page. Must be wrapped exactly as <div class='read-aloud'>...</div> with <p> tags inside - see below.
dmNotes object No Not a plain HTML string. A structured object - see below for every recognized key.
choices array Yes Array of choice objects

The readAloud Wrapper

Critical: readAloud is inserted directly into the DOM as HTML with no additional wrapping by the app. Every real bundled scene wraps it exactly like this:
"readAloud": "<div class='read-aloud'><p>First paragraph.</p><p>Second paragraph.</p></div>"

A bare <p> with no wrapping div (as older drafts of this documentation showed) will still render, but doesn't match the read-aloud styling any real campaign uses. Always use the div wrapper.

dmNotes Object Keys

Verified exhaustively against renderSceneDMNotes() - all of the following keys are individually recognized and rendered if present. Mix and match any subset:

Key Shape
overviewstring
objectivesarray of strings
information{ readily_available: [strings], if_asked: { topic_key: "answer" } }
skillChecks{ check_name: "result text" }
setupstring
enemies{ enemy_type: { count, hp, ac, stats, tactics } }
treasurestring
developmentNotesstring
aftermathstring
outcomesarray of strings, OR object of { key: value }
locations{ key: { description, clues: [], npc } }
key_npcs{ npc_key: "description" }
hooksarray of strings

Choice Structure

{ "text": "Choice button text", "nextScene": "ep1-scene2", "questActions": ["accept:Quest Name"] }

Choice Fields

Field Type Required Description
text string Yes Button text shown to the DM
nextScene string or null Yes A real sceneId elsewhere in the file (can be in a different episode - see below), or null to end that branch
questActions array No Each string is "accept:Exact Quest Name", "complete:Exact Quest Name", or "fail:Exact Quest Name" - see Quest Actions

Episode Transitions

The app auto-detects an episode transition: when a choice's nextScene points to a sceneId that lives in a different episode's scenes[] array than the current one, the app automatically updates which episode it thinks it's in. To move the DM from episode 1 into episode 2, just have episode 1's final scene's choice point nextScene at episode 2's first scene id (by convention ep2-scene1) - there is no special "end episode" flag or field.

Complete Example

{ "id": "example-campaign", "name": "The Lost Artifact", "levelRange": { "min": 1, "max": 3 }, "description": "Recover a stolen artifact.", "episodes": [ { "id": 1, "episodeNumber": 1, "title": "The Theft", "questTemplates": [ { "name": "Recover the Artifact", "type": "investigation", "description": "Find and recover the stolen artifact.", "rewards": { "gold": 150, "xp": 400 } } ], "scenes": [ { "sceneId": "ep1-scene1", "title": "The Museum", "readAloud": "<div class='read-aloud'><p>The curator of {nearestBurg}'s museum is frantic...</p></div>", "choices": [ { "text": "Accept the quest", "nextScene": "ep1-scene2", "questActions": ["accept:Recover the Artifact"] } ] } ] } ] }

Naming Conventions

Campaign ID

✓ Good: "rise-of-shadows", "lost-mines", "dragon-heist"
✗ Bad: "Rise of Shadows", "campaign_1", "MyGreatCampaign"

Use lowercase kebab-case.

Scene IDs

✓ Good: "ep1-scene1", "ep2-scene3-investigation"
✗ Bad: "scene1", "Episode1Scene3", "ep1_scene1"

Format: ep{number}-scene{number}-{optional-descriptor}

Validation

Common validation errors:

Error Cause Fix
Invalid JSON Syntax error in JSON Use JSON validator, check commas and quotes
Missing required field Required field omitted Add all required fields from tables above
Scene not found nextScene references non-existent scene Ensure all scene IDs match exactly
Quest not found questActions references non-existent quest Ensure quest name matches questTemplates exactly
Episode title shows "undefined" Episode object only has name, missing title Add title alongside name

Run node scripts/validate-campaign.js data/campaigns/NN-slug.json to check scene reference integrity and quest lifecycle completeness before shipping a new or edited campaign.

See Also