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/.
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:
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:
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.
| 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. |
- 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. - Add one
dungeons[]entry per selected quest, with aquestIdpointing at it. - 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. - Run
node scripts/validate-campaign.js your-campaign.json- it checksiduniqueness, thattype/difficultyare valid, and that every{dungeon:id}reference actually resolves to a real entry.
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:
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
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:
Rewards Shapes
The reward-rendering code accepts multiple shapes for the same field - use whichever is convenient:
Scene Structure
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
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:
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 |
|---|---|
overview | string |
objectives | array of strings |
information | { readily_available: [strings], if_asked: { topic_key: "answer" } } |
skillChecks | { check_name: "result text" } |
setup | string |
enemies | { enemy_type: { count, hp, ac, stats, tactics } } |
treasure | string |
developmentNotes | string |
aftermath | string |
outcomes | array of strings, OR object of { key: value } |
locations | { key: { description, clues: [], npc } } |
key_npcs | { npc_key: "description" } |
hooks | array of strings |
Choice Structure
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
Naming Conventions
Campaign ID
✗ Bad: "Rise of Shadows", "campaign_1", "MyGreatCampaign"
Use lowercase kebab-case.
Scene IDs
✗ 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
- Quick Start Guide - Step-by-step campaign creation
- {dungeon:id} - Full reference for the placeholder that opens a
dungeons[]entry - Placeholders - Dynamic location references
- Quest Actions - Controlling quest state