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

ExpressionProduces a value. A number, a string, a list.23 fields take one
PredicateProduces yes or no.17 fields
EffectSomething that happens.34 fields take a list
RuleA 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.

A numberexpression
12
A value from the worldexpression
{ "ref": "actor.level" }
A value that may be missingexpression
{ "ref": "actor.inventory.rope", "else": 0 }

Without else, a path that is not there is an error.

What you can read:

actor.levelAlso id, name, xp, alive, ancestry, class, proficiency, carried.
actor.attr.mightThe raw attribute score.
actor.mod.mightThe modifier your curve produced from it.
actor.res.hp · actor.max.hpCurrent and maximum of a resource.
actor.derived.guardAny derived stat you declared.
actor.skills.loreTrained rank plus equipment.
actor.inventory.ropeHow many carried. actor.equippedItems.blade for worn or wielded.
actor.conditions.burningRounds remaining, or true.
actor.primaryModThe 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_vessAnything you set.
quests.the_mill.statusAnd quests.the_mill.objectives.burn_it.
reputation.wardensStanding. ranks.wardens.trusted is the number a rank sits at.
lore.the_bell · threads.the_bell.knownKnown lore, and how many entries of a thread.
memory.speaker.theftWhat someone remembers. Also party, anyone, faction.
tiers.adeptThe rank a mastery tier begins at.
arcs.the_fenArc progress.
world.dayAlso hour, phase, month, monthName, year, minute.
purseMoney carried.
partyA list. party.0.name, or walk it with forEach.
A missing path is an error, not a zero
Add 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 is not readable from a formula
The 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

Addexpression
{ "add": [ { "ref": "actor.level" }, 2 ] }
Divide and round downexpression
{ "floor": { "div": [ { "sub": [ { "ref": "value" }, 10 ] }, 2 ] } }

The classic attribute modifier. In a modifier formula, value is the attribute score.

Roll diceexpression
{ "roll": "2d6+3" }
Keep inside a rangeexpression
{ "clamp": [ { "ref": "actor.level" }, 1, 10 ] }
Largest of severalexpression
{ "max": [ { "ref": "actor.mod.might" }, { "ref": "actor.mod.agility" }, 0 ] }
Choose between two valuesexpression
{ "cond": { "gte": [ { "ref": "actor.res.hp" }, 10 ] },
  "then": 2,
  "else": 0 }

Asking questions

Comparepredicate
{ "gte": [ { "ref": "actor.attr.might" }, 14 ] }
Is a flag setpredicate
{ "test": { "ref": "flags.met_vess" } }

test is true for anything except false, 0, an empty string, and null.

Is something absentpredicate
{ "not": { "exists": "actor.conditions.frightened" } }
All of thesepredicate
{ "all": [
    { "gte": [ { "ref": "actor.level" }, 3 ] },
    { "test": { "ref": "flags.mill_clear" } }
] }
Any of thesepredicate
{ "any": [
    { "gte": [ { "ref": "reputation.wardens" }, 20 ] },
    { "exists": "actor.inventory.warden_token" }
] }
One of a setpredicate
{ "in": [ { "ref": "world.phase" }, { "list": [ "dusk", "night" ] } ] }

The second side is a list expression, not a bare JSON array.

A one in four chancepredicate
{ "chance": 0.25 }

Rolled through the seeded dice, so it replays.

Making things happen

Deal damageeffects
[ { "damage": { "target": { "ref": "target.id" },
              "amount": { "roll": "2d6" },
              "damageType": "fire" } } ]
Heal, scaled by the castereffects
[ { "heal": { "target": { "ref": "target.id" },
            "amount": { "add": [ { "roll": "1d8" }, { "ref": "actor.primaryMod" } ] } } } ]
Apply a condition for three roundseffects
[ { "applyCondition": { "target": { "ref": "target.id" },
                      "condition": "burning",
                      "duration": 3 } } ]
Remember something happenedeffects
[ { "setFlag": { "flag": "rang_the_bell", "value": true } } ]
Hand something overeffects
[ { "grantItem": { "target": { "ref": "actor.id" },
                 "item": "brass_key", "quantity": 1 } } ]
Change standingeffects
[ { "adjustReputation": { "faction": "wardens", "amount": -5 } } ]
Teach a piece of loreeffects
[ { "learnLore": { "entry": "the_drowned_bell" } } ]
Only sometimeseffects
[ { "if": { "when": { "lt": [ { "ref": "actor.res.hp" }, 5 ] },
          "then": [ { "applyCondition": { "target": { "ref": "actor.id" },
                                          "condition": "desperate" } } ] } } ]
To everyone at onceeffects
[ { "forEach": { "in": { "ref": "party" }, "as": "member",
               "do": [ { "heal": { "target": { "ref": "member.id" }, "amount": 5 } } ] } } ]
Roll once, use twiceeffects
[ { "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 whole attribute, defined in three linesexpression

A curve that gives nothing below 8, then a point every three above it, capped at +6.

Goes in rules.attributes
{ "clamp": [
    { "floor": { "div": [ { "sub": [ { "ref": "value" }, 8 ] }, 3 ] } },
    0,
    6
] }
Encumbrance, which the engine does not haverule

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.

Goes in 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.

A spell that scales, and hurts less on a good saveeffects

Damage rising with caster level, a burn that only lands on a failed save, and a bigger burst at night.

Goes in 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.

A boss that changes when its guards dieeffects

Below a third health it heals itself, calls for help, and turns hostile to a faction that abandoned it.

Goes in 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.

A door that gets easier as you learn about itexpression

Starts at 22. Every clue you find on the thread takes 4 off it, down to a floor of 10. Asking around also helps.

Goes in 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.

A merchant who prices by how much they like youpredicate

Trusted customers, or anyone carrying the guild token and above level five, or anyone who has never been caught stealing.

Goes in 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.quantity

predicate

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.defeatWhen

effect

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.onComplete

rule

content.ancestries.traitscontent.items.procs

Where 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" } } }