Placeholders Overview
Placeholders are dynamic references to towns, dungeons, monsters, and NPCs. They let one campaign JSON file work for every party without you hardcoding names, and they keep the narration immersive instead of generic.
{nobleNPC}, etc.) work the same way.
Dungeon flavor-text placeholders are the one exception - they only
resolve to something specific if you've hand-authored an optional
data/maps/locations.json file (see its README);
otherwise they degrade to generic text. Either way, your campaign
JSON never needs to hardcode a name.
How Placeholders Work
When Campaign Cookbook loads a campaign, it:
- Generates (or loads a previously-generated) roster of named towns and NPCs for this specific party+campaign, seeded from that party's campaign seed
- Requests dungeon flavor-text data via
window.electronAPI.getMapData('dungeons'), which reads the optionaldata/maps/locations.json- there's no generation fallback for these - Resolves each simple placeholder (
{nearestBurg},{capitalCity}, etc.) once and caches the result - Resolves parameterized placeholders (
{randomBurg:minPopulation=500}) on demand, then caches that specific parameter combination - Falls back to fixed generic text for anything that couldn't be resolved - see below
Example
You write:
DM sees (the normal case - a town has been generated for this party):
DM sees (generation unavailable for some reason, and no locations.json either):
Available Placeholders
Settlement Placeholders
| Placeholder | Description | Parameters |
|---|---|---|
{nearestBurg} |
Closest settlement to starting location | None |
{randomBurg} |
Random settlement from the party's generated roster | minPopulation, maxPopulation (sameState/withinRange accepted but non-functional - see Parameter Syntax) |
{capitalCity} |
Capital city (highest population) | None |
{targetBurg} |
A second random settlement, distinct from {randomBurg}'s pick, resolved and cached once per campaign load |
None |
Dungeon Placeholders
| Placeholder | Description | Parameters |
|---|---|---|
{nearestDungeon} |
Closest dungeon to starting location | type, difficulty |
{randomDungeon} |
Random dungeon flavor-text entry (not a real, playable dungeon - see {nearestDungeon}) | type, difficulty (sameState accepted but non-functional) |
{targetDungeon} |
A second random dungeon, distinct from {randomDungeon}'s pick, resolved and cached once per campaign load |
None |
{dungeon:id} |
Not flavor text - opens a real, campaign-authored dungeons[] entry (fog-of-war rooms, seeded loot/encounters, Initiative Tracker hookup). See Campaign Structure for how to author one. |
None (the id itself selects the location) |
Content Placeholders
| Placeholder | Description | Parameters |
|---|---|---|
{monster:Name} |
Monster stat block from the bundled bestiary, looked up by name | count, variant (pipe-separated after the name, not comma-separated) |
NPC Placeholders
These resolve to a real generated D&D 5e NPC - a name, plus a full clickable stat block (ability scores, class/background flavor, appropriate gear) the DM can pull up on demand. Each one is generated once per campaign session and cached, so the same noble is referenced consistently everywhere they're mentioned. They take no parameters:
| Placeholder | Archetype |
|---|---|
{nobleNPC} |
Noble |
{merchantNPC} |
Merchant |
{cultistNPC} |
Cultist (religious archetype) |
{priestNPC} |
Priest (religious archetype) |
"{nobleNPC}, a scholar of ancient history in {targetBurg}, has discovered disturbing information..." renders as something like "Lord Aldric Vane, a scholar of ancient history in Millhaven, has discovered disturbing information..." - click the name in the reading pane to see their full stat block. Falls back to generic text ("the local noble," etc.) only if the NPC generator script didn't load for some reason.
Numbered Tags: Multiple Distinct People
A bare {nobleNPC} always resolves to the same generated person everywhere it appears in your campaign - the same noble met in episode 1 is still that exact person if referenced again in episode 4. That's deliberate: a quest-giver or recurring contact the party returns to should stay one consistent identity. But not every noble in a 6-episode campaign is the same person - a numbered suffix creates a genuinely different, independently generated identity:
Works the same way for all four archetypes ({merchantNPC_2}, {cultistNPC_2}, {priestNPC_2}, ...) - there's no fixed cap on how many you can use, and a campaign only ever generates the identities its own text actually references (no wasted generation for archetypes you never use).
There's also an open-ended form, {randomNPC_<profession>}
(e.g. {randomNPC_archmage}, {randomNPC_scholar}) -
unlike the four fixed archetypes above, this one is not
generated; it always resolves to plain generic text ("the archmage,"
"the scholar"). Use the four fixed archetypes when you want a real,
clickable stat block; use randomNPC_ only when you just
need a role mentioned in prose.
Reserved Location Placeholders
These four always resolve to the same fixed generic phrase - there's
no generation or lookup behind them yet, they exist as stable slots
for quest-hub location text seen across several bundled campaigns'
questGiver/location fields. They take no
parameters:
| Placeholder | Always resolves to |
|---|---|
{targetLocation} |
"the target location" |
{nearestTemple} |
"a nearby temple" |
{randomRuin} |
"ancient ruins in the region" |
{randomTower} |
"an old tower in the region" |
Simple vs Parameterized Placeholders
Simple Placeholders
No parameters. Just wrap the placeholder name in curly braces:
{capitalCity}
{nearestDungeon}
Parameterized Placeholders
Include parameters after a colon to filter location selection:
{randomBurg:minPopulation=500,maxPopulation=2000}
{nearestDungeon:type=Cave,difficulty=hard}
- Parameters follow the placeholder name after a colon
: - Multiple parameters separated by commas
, - No spaces around
=or, - Boolean values:
trueorfalse - Numbers:
50,1000 - Strings:
Cave,hard
Common Parameters
locations.json format
don't supply that data, so these are silently ignored rather than
filtering anything. See Parameter Syntax
for the full explanation. minPopulation/
maxPopulation, below, are the ones that actually work.
minPopulation / maxPopulation
Filters settlements by population size.
{randomBurg:maxPopulation=300} // Small village
{randomBurg:minPopulation=500,maxPopulation=2000} // Medium town
Use case: Match settlement size to story needs (village vs city).
type (Dungeons)
Filters dungeons by type.
{nearestDungeon:type=Ancient-Crypt}
{randomDungeon:type=Sewer}
Valid Types: Cave, Underground-Ruins, Sewer, Ancient-Crypt, Monster-Lair, Ruin
Use case: Match dungeon aesthetics to quest theme (crypts for undead, caves for bandits).
difficulty (Dungeons)
Filters dungeons by difficulty level.
{nearestDungeon:difficulty=hard}
{randomDungeon:difficulty=deadly}
Valid Difficulties: easy, medium, hard, deadly
Use case: Match dungeon challenge to party level.
Consistency & Caching
Each unique placeholder + parameters combination is resolved once and cached for the entire campaign.
Caching Behavior
| Placeholder | First Use | Second Use |
|---|---|---|
{randomBurg:maxPopulation=300} |
Resolves to "Millhaven" | Returns "Millhaven" (cached) |
{randomBurg:minPopulation=1000} |
Resolves to "Eastport" | Returns "Eastport" (cached) |
Different parameters = different cached value
Fallback Behavior
Three distinct situations produce fallback text, and it's worth telling them apart:
Town/NPC generation didn't run for some reason (rare
- this only happens if the generator script failed to load). Every
location and NPC placeholder degrades to specific generic text,
verified exactly against useFallbackPlaceholders():
{nearestBurg}→ "the nearest settlement"{randomBurg}→ "a nearby town"{capitalCity}→ "the capital city"{targetBurg}→ "the target location"{nobleNPC}→ "the local noble"{merchantNPC}→ "a local merchant"{cultistNPC}→ "a cultist"{priestNPC}→ "the priest"
No hand-authored data/maps/locations.json
(the common, out-of-the-box case - dungeon flavor text has no
generation fallback the way towns do):
{nearestDungeon}→ "a nearby dungeon"{targetDungeon}→ "the target dungeon"
A parameterized placeholder's filters match nothing. Only that specific placeholder falls back, to a shorter generic phrase:
{randomBurg:...}with no matches → "a nearby settlement"{nearestDungeon:...}/{randomDungeon:...}with no matches → "a nearby dungeon"
minPopulation/maxPopulation.
If you see it for {nearestDungeon}/{randomDungeon},
it likely just means you haven't authored a locations.json yet.
Usage Examples
Basic Settlement Reference
Local Travel
Filtered Destination
Dungeon Reference
Best Practices
Always Use Placeholders for Locations
✗ Bad: "Travel to Millhaven"
A hardcoded name assumes every party's generated roster happens to include a town by that exact name - it won't.
Keep Adventures Small Early On
✗ Risk: {randomBurg} // Could resolve to a major city
See Scope Control for the full strategy.
Match Population to Context
✗ Bad: "A small village: {randomBurg}" // Might select a city
Match Dungeon Type to Story
✗ Bad: "The undead lair: {nearestDungeon}" // Might select a cave
Next Steps
- Detailed {randomBurg} Guide - Most flexible placeholder
- Detailed {nearestDungeon} Guide - Dungeon filtering
- Parameter Syntax - Complete parameter reference
- Scope Control - Guide to keeping adventures local