Quick Start Guide

Create your first dynamic campaign in 10 minutes. This guide walks you through the essential concepts and shows you how to write a simple quest, using only fields the Campaign Cookbook actually reads.

What You'll Build

A simple investigation quest with:

Step 1: Campaign File Structure

Create a new JSON file in data/campaigns/, named NN-slug.json to match the bundled campaigns' convention, and add an entry for it in data/campaigns/index.json:

data/campaigns/31-my-campaign.json

Step 2: Basic Campaign Structure

Start with this minimal campaign structure. Only name, levelRange, description, and episodes actually matter to the app - there is no title, author, difficulty, or recommendedLevel field:

{ "id": "my-first-campaign", "name": "The Bandit Threat", "levelRange": { "min": 1, "max": 3 }, "description": "A group of bandits threatens local settlements.", "episodes": [] }
Why name, not title? The app reads campaign.name everywhere it shows the campaign title (baseCampaign?.name). It never reads a top-level title, author, or campaignId field - those were fictional in older docs and have been removed here.

Step 3: Add an Episode

Episodes contain your quest structure. Add your first episode. Set both id and episodeNumber - the app's episode lookup accepts either, but a handful of the real bundled campaigns shipped with only id and needed a runtime patch to backfill episodeNumber, so setting both up front avoids that trap. Likewise set title, not just name - episode.title is the field showEpisodeIntro() actually displays, and several bundled campaigns that only had name showed a literal "undefined" episode title until that was fixed:

{ "episodes": [ { "id": 1, "episodeNumber": 1, "title": "The Bandit Raid", "description": "Bandits have been raiding caravans near {nearestBurg}...", "questTemplates": [], "scenes": [] } ] }
Notice: We used {nearestBurg} - a dynamic placeholder that will be replaced with a real settlement name if data/maps/locations.json is configured, or generic fallback text ("the nearest settlement") if it isn't.

Step 4: Define Your Quest

Add a quest template to the episode:

"questTemplates": [ { "name": "Stop the Bandits", "type": "combat", "description": "Track down the bandit hideout and stop the raids.", "rewards": { "gold": 100, "xp": 300, "items": ["Bandit Leader's Map"] } } ]

Step 5: Create Scenes

Now add the story scenes. Start with the quest introduction. readAloud is inserted directly into the page as HTML, so it must be wrapped exactly as <div class='read-aloud'>...</div> with <p> tags inside - a bare <p> with no wrapping div will still display, but doesn't match what every real bundled campaign does and loses the read-aloud styling:

"scenes": [ { "sceneId": "ep1-scene1", "title": "The Town Guard's Request", "readAloud": "<div class='read-aloud'><p>The guard captain of {nearestBurg} approaches you in the tavern. 'Bandits have been raiding caravans on the road to {randomBurg:maxPopulation=300}. We need help tracking them down.'</p></div>", "choices": [ { "text": "Accept the quest to stop the bandits", "nextScene": "ep1-scene2", "questActions": ["accept:Stop the Bandits"] }, { "text": "Decline and leave", "nextScene": null } ] } ]
Quest Actions: The questActions array controls quest state. When the DM clicks "Accept the quest to stop the bandits", "accept:Stop the Bandits" is matched against the name field of a quest template in the current episode's questTemplates - it must match character for character.

Step 6: Add Investigation Scene

Add a scene where the party investigates the attack site:

{ "sceneId": "ep1-scene2", "title": "The Attack Site", "readAloud": "<div class='read-aloud'><p>You arrive at the site of the most recent attack. Overturned wagons and scattered goods litter the roadside. Tracks lead into the forest.</p></div>", "choices": [ { "text": "DC 10 Survival - Follow the tracks (PASS)", "nextScene": "ep1-scene3-success" }, { "text": "DC 10 Survival - Tracks go cold (FAIL)", "nextScene": "ep1-scene3-failure" } ] }

Step 7: Add Branching Outcomes

Create different outcomes based on the DC check. Scene ids follow the convention ep{episodeNumber}-scene{N} for the main path and ep{episodeNumber}-scene{N}-{descriptor} for branches:

// Success path { "sceneId": "ep1-scene3-success", "title": "The Bandit Hideout", "readAloud": "<div class='read-aloud'><p>Following the tracks, you discover a hidden cave - {nearestDungeon:type=Cave}. Voices echo from within.</p></div>", "choices": [ { "text": "Attack the hideout and defeat the bandits", "nextScene": "ep1-scene4-complete", "questActions": ["complete:Stop the Bandits"] } ] }, // Failure path { "sceneId": "ep1-scene3-failure", "title": "Trail Goes Cold", "readAloud": "<div class='read-aloud'><p>The tracks fade into rocky terrain. You've lost the trail.</p></div>", "choices": [ { "text": "Return to {nearestBurg} empty-handed", "nextScene": null, "questActions": ["fail:Stop the Bandits"] } ] }

Step 8: Add Completion Scene

Create the final scene for quest completion:

{ "sceneId": "ep1-scene4-complete", "title": "Victory!", "readAloud": "<div class='read-aloud'><p>You defeat the bandits and recover the stolen goods. The guard captain thanks you and pays the promised reward.</p><p>The people of {nearestBurg} can travel safely once again.</p></div>", "choices": [ { "text": "End Episode", "nextScene": null } ] }

Complete Example

Here's the full campaign file - every field in it is one the app actually reads:

{ "id": "my-first-campaign", "name": "The Bandit Threat", "levelRange": { "min": 1, "max": 3 }, "description": "A group of bandits threatens local settlements.", "episodes": [ { "id": 1, "episodeNumber": 1, "title": "The Bandit Raid", "description": "Bandits have been raiding caravans near {nearestBurg}...", "questTemplates": [ { "name": "Stop the Bandits", "type": "combat", "description": "Track down bandits and stop the raids.", "rewards": { "gold": 100, "xp": 300 } } ], "scenes": [ /* Scenes from steps 5-8 above */ ] } ] }

Step 9: Test Your Campaign

To test your campaign:

  1. Place your campaign JSON in data/campaigns/
  2. Add an entry to data/campaigns/index.json (id, file, and the level-range bucket it belongs to)
  3. Launch the app (npm run dev) or use the Campaign Testbed to load the file directly
  4. Select your campaign from the campaign grid
  5. Click through the scenes to verify choices, quest actions, and placeholders all resolve correctly

Key Concepts Learned

1. Dynamic Placeholders

{nearestBurg} // Closest settlement
{randomBurg:maxPopulation=300} // Random local settlement
{nearestDungeon:type=Cave} // Closest cave dungeon

2. Quest Actions

"questActions": ["accept:Quest Name"] // Accept quest
"questActions": ["complete:Quest Name"] // Complete quest
"questActions": ["fail:Quest Name"] // Fail quest

3. Branching Choices

Use nextScene to control story flow. null ends that branch (no further scene is shown). To move the DM into the next episode, simply point a choice's nextScene at a scene id that lives in a different episode's scenes[] array - the app auto-detects the episode transition, there's no special "end episode" field.

4. DC Checks

Present both success and failure options as separate choices for the GM to select.

Next Steps

Enhance Your Campaign

Advanced Features

Congratulations! You've created your first dynamic campaign. Location placeholders mean the same campaign JSON works for any party - each one gets its own generated set of towns, so every playthrough feels a little different without you writing multiple versions of the same scene.