Writing formulas
The small JSON language that runs everything conditional in your world.
Data says what a thing is. A formula says what it does, and when. The same language covers ability effects, item procs, trap triggers, dialogue gates, loot rules, quest objectives, difficulty numbers, and stat curves.
Four kinds
| Expression | Produces a value. A number, a string, a list. | 23 fields take one |
| Predicate | Produces yes or no. | 17 fields |
| Effect | Something that happens. | 34 fields take a list |
| Rule | A predicate and the effects it gates. | 2 fields |
A field's type in the reference tells you which one it wants. Every operator is listed on the DSL page.
Reading values
One mechanism reads everything. A dotted path into the world.
12{ "ref": "actor.level" }{ "ref": "actor.inventory.rope", "else": 0 }Without else, a path that is not there is an error.
What you can read:
actor.level | Also id, name, xp, alive, ancestry, class, proficiency, carried. |
actor.attr.might | The raw attribute score. |
actor.mod.might | The modifier your curve produced from it. |
actor.res.hp · actor.max.hp | Current and maximum of a resource. |
actor.derived.guard | Any derived stat you declared. |
actor.skills.lore | Trained rank plus equipment. |
actor.inventory.rope | How many carried. actor.equippedItems.blade for worn or wielded. |
actor.conditions.burning | Rounds remaining, or true. |
actor.primaryMod | The modifier of the class's primary attribute. |
target.* | Same shape, where there is a target. id, name, level, alive, attr, mod, res, max, derived, conditions. |
flags.met_vess | Anything you set. |
quests.the_mill.status | And quests.the_mill.objectives.burn_it. |
reputation.wardens | Standing. ranks.wardens.trusted is the number a rank sits at. |
lore.the_bell · threads.the_bell.known | Known lore, and how many entries of a thread. |
memory.speaker.theft | What someone remembers. Also party, anyone, faction. |
tiers.adept | The rank a mastery tier begins at. |
arcs.the_fen | Arc progress. |
world.day | Also hour, phase, month, monthName, year, minute. |
purse | Money carried. |
party | A list. party.0.name, or walk it with forEach. |
else where something may genuinely be absent. Five namespaces read as null instead of failing, since not yet is a normal answer for them: flags, quests, memory, reputation, lore. Everything else is strict, so a typo is caught.extra bag on an entry is carried through, exported, and available to mods, but it is not in the scope a ref path walks. Use a flag for author data a formula needs to read.Doing arithmetic
{ "add": [ { "ref": "actor.level" }, 2 ] }{ "floor": { "div": [ { "sub": [ { "ref": "value" }, 10 ] }, 2 ] } }The classic attribute modifier. In a modifier formula, value is the attribute score.
{ "roll": "2d6+3" }{ "clamp": [ { "ref": "actor.level" }, 1, 10 ] }{ "max": [ { "ref": "actor.mod.might" }, { "ref": "actor.mod.agility" }, 0 ] }{ "cond": { "gte": [ { "ref": "actor.res.hp" }, 10 ] },
"then": 2,
"else": 0 }Asking questions
{ "gte": [ { "ref": "actor.attr.might" }, 14 ] }{ "test": { "ref": "flags.met_vess" } }test is true for anything except false, 0, an empty string, and null.
{ "not": { "exists": "actor.conditions.frightened" } }{ "all": [
{ "gte": [ { "ref": "actor.level" }, 3 ] },
{ "test": { "ref": "flags.mill_clear" } }
] }{ "any": [
{ "gte": [ { "ref": "reputation.wardens" }, 20 ] },
{ "exists": "actor.inventory.warden_token" }
] }{ "in": [ { "ref": "world.phase" }, { "list": [ "dusk", "night" ] } ] }The second side is a list expression, not a bare JSON array.
{ "chance": 0.25 }Rolled through the seeded dice, so it replays.
Making things happen
[ { "damage": { "target": { "ref": "target.id" },
"amount": { "roll": "2d6" },
"damageType": "fire" } } ][ { "heal": { "target": { "ref": "target.id" },
"amount": { "add": [ { "roll": "1d8" }, { "ref": "actor.primaryMod" } ] } } } ][ { "applyCondition": { "target": { "ref": "target.id" },
"condition": "burning",
"duration": 3 } } ][ { "setFlag": { "flag": "rang_the_bell", "value": true } } ][ { "grantItem": { "target": { "ref": "actor.id" },
"item": "brass_key", "quantity": 1 } } ][ { "adjustReputation": { "faction": "wardens", "amount": -5 } } ][ { "learnLore": { "entry": "the_drowned_bell" } } ][ { "if": { "when": { "lt": [ { "ref": "actor.res.hp" }, 5 ] },
"then": [ { "applyCondition": { "target": { "ref": "actor.id" },
"condition": "desperate" } } ] } } ][ { "forEach": { "in": { "ref": "party" }, "as": "member",
"do": [ { "heal": { "target": { "ref": "member.id" }, "amount": 5 } } ] } } ][ { "let": { "name": "burst", "value": { "roll": "3d6" },
"in": [ { "damage": { "target": { "ref": "target.id" },
"amount": { "ref": "burst" }, "damageType": "fire" } },
{ "damage": { "target": { "ref": "actor.id" },
"amount": { "div": [ { "ref": "burst" }, 2 ] },
"damageType": "fire" } } ] } } ]Without let, each roll would be rolled separately and the two numbers would differ.
Putting it together
Six real ones. Every example on this page is parsed and validated against the format before the page is built.
A curve that gives nothing below 8, then a point every three above it, capped at +6.
rules.attributes{ "clamp": [
{ "floor": { "div": [ { "sub": [ { "ref": "value" }, 8 ] }, 3 ] } },
0,
6
] }A carrying capacity from Might, and a condition that lands when you exceed it. The engine ships no encumbrance rule. This is the whole of one.
rules.derivedStats, then an ancestry trait{ "when": { "gt": [ { "ref": "actor.carried" }, { "ref": "actor.derived.capacity" } ] },
"then": [ { "applyCondition": { "target": { "ref": "actor.id" },
"condition": "burdened",
"duration": 1 } } ] }capacity is a derived stat: { "mul": [ { "ref": "actor.attr.might" }, 15 ] }. A rule like this runs every turn as a passive, from an ancestry trait or an item proc.
Damage rising with caster level, a burn that only lands on a failed save, and a bigger burst at night.
content.abilities, onUse[ { "let": { "name": "blast",
"value": { "add": [ { "roll": "4d6" },
{ "mul": [ { "floor": { "div": [ { "ref": "actor.level" }, 4 ] } }, 3 ] },
{ "cond": { "in": [ { "ref": "world.phase" },
{ "list": [ "dusk", "night" ] } ] },
"then": 4, "else": 0 } ] },
"in": [ { "damage": { "target": { "ref": "target.id" },
"amount": { "ref": "blast" },
"damageType": "fire" } },
{ "if": { "when": { "not": { "exists": "target.conditions.warded" } },
"then": [ { "applyCondition": { "target": { "ref": "target.id" },
"condition": "burning",
"duration": 3 } } ] } } ] } } ]Put this on an ability with a savingThrow whose onSuccess is half, and the engine halves the damage for you.
Below a third health it heals itself, calls for help, and turns hostile to a faction that abandoned it.
content.monsters, reactions[ { "if": { "when": { "lte": [ { "ref": "actor.res.hp" },
{ "div": [ { "ref": "actor.max.hp" }, 3 ] } ] },
"then": [ { "heal": { "target": { "ref": "actor.id" },
"amount": { "roll": "4d8" } } },
{ "applyCondition": { "target": { "ref": "actor.id" },
"condition": "enraged", "duration": 5 } },
{ "emit": { "event": "warden_called_for_help",
"data": { "where": "the_mill" } } },
{ "adjustReputation": { "faction": "wardens", "amount": -10 } } ],
"else": [ { "applyCondition": { "target": { "ref": "target.id" },
"condition": "frightened", "duration": 2 } } ] } } ]The emitted event is yours. A trigger or an objective elsewhere can wait on warden_called_for_help.
Starts at 22. Every clue you find on the thread takes 4 off it, down to a floor of 10. Asking around also helps.
world.pointsOfInterest, discover.difficulty{ "max": [
10,
{ "sub": [ 22,
{ "add": [ { "mul": [ { "ref": "threads.the_drowned_bell.known" }, 4 ] },
{ "cond": { "test": { "ref": "flags.asked_at_the_inn" } },
"then": 3, "else": 0 } ] } ] }
] }threads.<id>.known counts the lore entries on that thread the party has learned.
Trusted customers, or anyone carrying the guild token and above level five, or anyone who has never been caught stealing.
A gate on the good stock, npc.shop.requires custom{ "any": [
{ "gte": [ { "ref": "reputation.merchants" }, { "ref": "ranks.merchants.trusted" } ] },
{ "all": [ { "exists": "actor.inventory.guild_token" },
{ "gte": [ { "ref": "actor.level" }, 5 ] } ] },
{ "not": { "test": { "ref": "memory.speaker.theft" } } }
] }memory.speaker is what this person knows, not what is true. A theft nobody saw does not count.
Things that catch people out
| A bare array is not an expression. | { "list": [ "a", "b" ] }, not [ "a", "b" ]. |
| Effects always come as a list. | Even one. Wrap it in [ ]. |
Most effects need a target. | Usually { "ref": "actor.id" } or { "ref": "target.id" }. |
| The same roll twice is two rolls. | Use let to roll once and reuse it. |
all of nothing is true. | any of nothing is false. |
target only exists where there is one. | Not in a quest condition or a gate. |
A modifier formula sees only value. | Not the rest of the character. |
repeat stops at ten thousand. | Loop bindings do not leak out of the loop. |
Every field that takes one
Generated from the format.
expression
rules.attributes.modifierrules.resources.maxrules.resources.minrules.resources.initialrules.derivedStats.formularules.conditions.defaultDurationrules.conditions.savingThrow.difficultyrules.resolution.attackBonusrules.progression.proficiencyrules.progression.levelVitality.bonusrules.savingThrows.defaultDifficultyrules.spellcasting.saveDifficultyrules.spellcasting.attackBonusrules.spellcasting.concentration.difficultycontent.abilities.savingThrow.difficultycontent.monsters.reactions.roll.difficultycontent.traps.detect.difficultycontent.traps.disarm.difficultyworld.areas.encounterChanceworld.pointsOfInterest.discover.difficultyworld.gates.bypass.difficultynarrative.quests.rewards.xpnarrative.quests.rewards.items.quantitypredicate
rules.opportunities.requirescontent.abilities.whencontent.abilities.requires.customcontent.abilities.requires.anyOf.customcontent.monsters.behaviour.whencontent.monsters.reactions.whencontent.monsters.specialTurns.whenworld.biomes.triggers.whenworld.biomes.triggers.completeWhennarrative.textGrammar.variants.whennarrative.dialogues.nodes.options.whennarrative.quests.availablenarrative.quests.failWhennarrative.quests.objectives.whennarrative.memory.rules.whenstart.victoryWhenstart.defeatWheneffect
rules.resources.onDepletedrules.conditions.onApplyrules.conditions.onTickrules.conditions.onExpirerules.rests.effectsrules.progression.levels.grantsrules.opportunities.effectscontent.abilities.upcastcontent.abilities.onUsecontent.abilities.onMisscontent.abilities.onCriticalcontent.abilities.savingThrow.onSuccessEffectscontent.items.onUsecontent.monsters.reactions.onSuccesscontent.monsters.reactions.onFailurecontent.monsters.reactions.effectscontent.traps.onTriggercontent.traps.onDisarmworld.terrains.onEnterworld.terrains.onOccupyworld.biomes.triggers.effectsworld.gates.onOpenworld.gates.onBlockedworld.gates.bypass.onSuccessworld.gates.bypass.onFailureworld.encounterTables.groups.onEncounternarrative.dialogues.nodes.onEnternarrative.dialogues.nodes.options.effectsnarrative.quests.onStartnarrative.quests.onCompletenarrative.quests.onFailnarrative.quests.stages.onStartnarrative.quests.stages.onCompletenarrative.quests.objectives.onCompleterule
content.ancestries.traitscontent.items.procsWhere to go next
The DSL page lists every operator. Requirements is the structured alternative for gating, and takes a formula through custom where its clauses run out.
"requires": { "minLevel": 3,
"custom": { "test": { "ref": "flags.spoke_for_us" } } }