Skip to content

Dice and actions

Dice notation is the familiar VTT notation, written inside formulas:

Notation Meaning
NdS N dice with faces 1..S (1d8)
Nd% percentile, same as d100
NdF Fate dice (−1, 0, +1)
(expr)dS computed count (PROVISIONAL)

The die types and dice modifiers are fixed sets, versioned with the spec. A game that needs a new modifier waits for a spec version that adds it. Apps need to know the set in order to display rolls (“kept the higher die”).

Modifier Notation Example
keepHighest / keepLowest khN / klN 2d20kh1
dropHighest / dropLowest dhN / dlN 4d6dl1
explode / explodeCompound ! / !! 1d6!
reroll / rerollOnce rN / roN 1d20r1
countSuccesses / countFailures cs>=N / cfN 5d6cs>=5
minimum / maximum minN / maxN 1d20min10

How to read a result (bands, success tiers) is game data (result bands), not engine logic.

PROVISIONAL shape (roll-item.schema.json).

A roll is a list of roll items. Each item is structured data, not a display string, and has exactly one source. Pieces that add together are separate items. Apps build their own labels.

{ "kind": "flat", "value": 3, "source": { "type": "field", "id": "5e-srd-5.1:abilities.str.mod" }, "tags": ["attack"] }
{ "kind": "dice", "count": 2, "sides": 20, "modifiers": [{ "type": "keepHighest", "count": 1 }],
"faces": [{ "value": 7, "kept": false }, { "value": 15, "kept": true }], "value": 15,
"source": { "type": "situational", "id": "5e-srd-5.1:advantage" } }
  • count is the number of dice actually rolled. Advantage on 1d20 is count: 2 with keepHighest 1.
  • faces lists every die in order, including dropped, rerolled (rerolled: true) and exploded (exploded: true) dice.
  • multiplied: false marks an item added by an effect, or from a roll part, with multiplied: false, which multiplyDice and multiply left alone.
  • factor is the product of the multiply factors applied to the item, whose value is then the multiplied one.
  • source.type is one of field, item, entry, template, effect, situational, rules, step or app.
  • How a source got its value (proficiency is +2 because of level) is not part of the roll. An app that wants to explain it follows the field’s breakdown.

An action (action.schema.json) is a cascade of steps, such as attack roll → hit or miss → damage. Actions are declared on content types (every item of the type gets them), on items, or in the rules as shared actions that items use (optionally fixing params).

Property Meaning
available When the action is offered (self.equipped)
params Values the app supplies (a target’s AC, which stat to test), declared like state
participants Roles other characters fill (a target, a helper), each with min, max and declared fields
steps The cascade
results The results its outcome can have: a list of IDs, or { bands: <set> } for a band set’s bands

Formulas in an action see item, self, params.<name>, situational.<id>, participants.<role>, steps.<id>.* and flags.<name>. Where item and self mean something else (a choice step’s candidates, a formula a change passes on), the action’s own are action.item and action.self, and the current step is action.step (id, tags).

Text for an action. Everything an action puts in front of the player has a translation key built from the action’s key root, <actionKey> (actions.<id> for a rules action, <contentId>.actions.<id> for an action on an item; see i18n.md):

  • a choice step’s question: <actionKey>.steps.<step id>.prompt (or the step’s prompt);
  • a parameter’s label: <actionKey>.params.<name>.name (or the parameter’s label);
  • a roll step’s name: <actionKey>.steps.<step id>.name (or the step’s label), optional;
  • each declared result: <actionKey>.results.<result>.name, or bands.<set>.<band>.name for results: { bands: <set> }.

An outcome result the action doesn’t declare is the error undeclared-result; the type checker reports a literal one at build time. An action without results isn’t checked, and its results have no text (pnpm validate warns).

- id: weapon-attack
results: [hit, miss] # actions.weapon-attack.results.hit.name, ….miss.name
params:
targetAc: { type: integer, optional: true } # actions.weapon-attack.params.targetAc.name
steps:
- id: confirm
kind: choice # actions.weapon-attack.steps.confirm.prompt
options: [{ id: hit }, { id: miss }]

Computed tags. In an action, any tags list (on a roll step, a roll part, an action step, a roll effect’s added item, or a proposed change) may contain { formula } entries. These are evaluated when the step runs. A formula gives one tag (a string) or several (a list of strings), spread in the entry’s place, in order; an empty list adds nothing. Each must be a declared tag (unknown-tag otherwise), anything else is type-mismatch, and a tag already in the list (from an earlier entry) isn’t added again. One shared attack action can then tag a longsword’s damage damage.slashing and a bow’s damage.piercing:

- id: damage
kind: roll
tags: [damage.weapon]
parts:
- dice: item.damage
tags: [{ formula: "'damage.' + item.damageType" }]

A roll of a field passed as a parameter carries the field’s tags, however many it has (orc-2e’s check: check, skill, skill.athletics, attr.str):

- id: roll
kind: roll
tags: [d20, { formula: params.check.tags }]
parts:
- dice: 1d20
- field: params.check.id

Typed damage. A proposed adjust with split: { roll: <step>, by: <tag> } becomes one change per distinct tag under by among that roll’s items:

  • The engine evaluates the change’s by formula once per group, with part.total (the group’s sum) and part.tag bound.
  • Items with no such tag form one more group, which carries only the change’s own tags.
  • Incoming-change effects on the target then apply per change, so resistance halves only the fire part and rounds per damage type.
- id: deal
kind: changes
to: target
changes:
- type: adjust
field: hp.current
split: { roll: damage, by: damage }
by: -max(0, part.total)
tags: [damage]

Changes for a linked character. to: { link: <slot> } sends a step’s changes to the character filling one of the actor’s link slots (rules-and-fields.md). They are that character’s changes, so they name its fields and content by qualified ID; the build checks them against the slot’s pack when the repo has it. A conspicuous creation’s +1 heat for the crew (FitD):

- id: heat
kind: changes
when: self.conspicuous && has(links.crew)
to: { link: crew }
changes:
- { type: adjust, field: 'fitd-crew:heat', by: 1 }

Adding items. A proposed addItem may give its entry as an object whose values are formulas: item (a string naming a content ID; local IDs resolve in the action’s pack), quantity (an integer), equipped (a boolean) and state values. id, templates and choices are literal (but for computed picks, below), and id may be left out for the engine to pick an unused one. Any proposed change may also have a when condition. A rolled starting weapon:

- id: gear
kind: changes
changes:
- type: addItem
entry:
item: "'weapons.' + ['femur', 'staff', 'shortsword', 'knife'][steps.weapon.total - 1]"
equipped: true
- type: addItem
when: steps.weapon.total == 7
entry: { item: "'ammo.arrows'", quantity: abilities.presence + 10 }

The returned change is an ordinary addItem. A literal item is checked at build time; a computed one when the action runs.

A pick’s option in choices may be { formula }, evaluated by the action (with x under forEach) to a string or number; the tree’s keys stay literal. applyChanges checks an added copy’s picks as it would a choose of each (unknown-choice, invalid-option). 5E’s create-monster adds one copy of a generic innate-spell item per spell the stat block lists:

- type: addItem
forEach: steps.monster.item.innateOncePerDay
entry:
item: "'monster-actions.innate-spell'"
state: { perDay: 1 }
choices: { spell: [{ option: { formula: x } }] }

Computed fields and forEach. A proposed set or adjust may name its field with a formula, field: { formula: <string> }. It is evaluated when the change is resolved and qualified against the action’s pack, as a literal field is; it must name an input field (unknown-field, not-input-field), or for a link a qualified ID. A split adjust’s field is written out. Any proposed change may also have forEach: <list>: it is proposed once per element, in list order, with x bound to the element, as in the list functions; its when is evaluated per element, and an empty list proposes nothing. FitD crew creation marks each of a table’s own crew type’s starting upgrades (qualified field IDs) in one change:

- id: start
kind: changes
changes:
- { type: adjust, field: coin.current, by: 2 }
- type: adjust
forEach: 'has(steps.type) ? steps.type.item.startingUpgrades : []'
field: { formula: x }
by: 1

Fate’s minor milestone switches two skills the player names as field parameters. Both values are read before either change applies, so the swap is exact:

changes:
- { type: set, field: { formula: params.skillA.id }, value: params.skillB.value }
- { type: set, field: { formula: params.skillB.id }, value: params.skillA.value }

The build can’t check a computed field, as it can’t check a computed addItem item. Each copy of a forEach change counts toward maxActionSteps. x is not bound in the formulas a change aimed at copies passes on (copies.where, per-copy by and value).

A computed entry. A proposed change’s entry (other than an addItem’s new copy) may be { formula } giving an instance ID, evaluated when the change is resolved and passed on as the ID: to a participant too, whose own applyChanges checks it. An app passes the copy as a parameter. orc-2e’s runic weapon puts its template on the ally’s weapon:

- type: applyTemplate
entry: { formula: flags.copy } # params.copy: the weapon's instance ID on the ally's sheet
template: "'templates.runic'"

Picking in a choice. A proposed choose or unchoose gives its path and option. path.entry is an instance ID, self, picked or { formula }, as for entry, and is left out for a rules-level choice. path.choice is the path as a list of literal segments, or a formula giving the list; option is a formula. An unchoose may leave option out to remove every pick of the choice, so an action can replace a pick it can’t read: 5E’s shapechangers change form with an unchoose of their form choice, then a choose. A creation action for a table’s own FitD playbooks could pick the playbook, then the special ability inside the picked playbook:

- id: playbook
kind: choice
options: { content: { type: playbook } }
changes:
- { type: choose, path: { choice: [playbook] }, option: picked.id }
- id: ability
kind: choice
options:
content: { type: specialAbility, where: 'steps.playbook.item.tags[0] in item.tags' }
changes:
- type: choose
path: { choice: "['playbook', steps.playbook.picked, 'special-ability']" }
option: picked.id

Changes to copies by query. A proposed setState, adjustState, setQuantity, adjustQuantity, equip or removeItem may name its copies with copies: { type, where } (and limit) instead of entry. The character receiving the change resolves the query when it applies it, so a to: target change can reach the target’s copies, which the action never sees. In where, and in a by or value that reads them, item and self are each matching copy’s item and copy:

# MÖRK BORG attack, on a crit: the target's worn armor drops a tier.
- id: sunder
kind: changes
to: target
changes:
- type: adjustState
copies: { type: armor, where: self.equipped && self.damage < item.tier }
limit: 1
key: damage
by: 1

The action’s values in a passed-on formula. where, and a by or value passed on per copy, may also read steps, flags, params and action. The action replaces each such path with its value as a literal before passing the change on, and has() of one with true or false; the returned change reads only item, self, character and fields. A path to a map (steps.roll) has no literal and is a type mismatch: read a member (steps.roll.band). These names mean nothing in a change an app writes for applyChanges.

Spending a copy’s quantity. setQuantity and adjustQuantity change a copy’s quantity. It never goes below 0, so an action that spends from a stack aims at copies with some left: if none match, nothing is spent and nothing fails. A copy at 0 stays; a later removeItem by query can remove it. Here item in where is the ammunition, so the bow is action.item:

# MÖRK BORG attack with a bow or crossbow: every shot spends one, hit or miss.
- id: shoot
kind: changes
when: has(item.ammo)
changes:
- type: adjustQuantity
copies: { type: ammo, where: 'item.kind == action.item.ammo && self.quantity > 0' }
limit: 1
by: -1

With a bow, the proposed change reads where: item.kind == 'arrows' && self.quantity > 0.

Applying content. A proposed applyEffect names its source with a string formula, qualified against the action’s pack as an addItem’s item is; active and each state value are formulas too. It may leave out id: the character receiving the change picks one (<source local id, dots as hyphens>-<n>), so an action can apply a condition to a target whose file it never sees. A potion applies itself to the drinker; a monster’s hit applies its condition, saying who applied it:

# 5E monster-attack: the condition a hit causes, unless the target saved.
- id: condition
kind: changes
each: target
when: flags.landed && has(item.condition) && (!has(steps.saves) || steps.saves.result != 'success')
to: target
changes:
- type: applyEffect
source: item.condition
state: { by: creature.type, magical: has(item.spell) && item.spell }

Equipping and templates. A proposed equip computes equipped (a boolean formula), for the copy entry names or every copy copies matches; a proposed applyTemplate or removeTemplate names its template with a string formula, qualified against the action’s pack as source is. setExtension stays the app’s, as setHomebrew does: extension data lives under a namespace a pack can’t know. 5E’s Oil of Sharpness coats a weapon:

- id: weapon
kind: choice
options:
copies: { type: weapon, where: "item.damageType in ['slashing', 'piercing']" }
changes:
- { type: applyTemplate, entry: picked, template: "'magic-items.sharpness-coating'" }

The action’s own pick. In an action of picked content, a proposed setState or adjustState with pick: self names the pick that activated it: the returned change carries its path and option. picks: { where, limit? } names picks by query and is passed on as applied is. Orc Ferocity marks its daily use:

- { type: setState, pick: self, key: used, value: true }

Changes to applied entries by query. removeEffect, toggleEffect, setState and adjustState may name applied entries with applied: { where, limit? } instead of id (or entry). As for copies, the receiving character resolves it: its applied entries, in file order, whose where holds with item the source’s item and self the entry (id and its state, with declared defaults). where, and a by or value that reads self or item, are passed on with the action’s values substituted. setState and adjustState may also name one applied entry by its ID in entry.

# 5E long rest: sight rot worsens by 1.
- type: adjustState
applied: { where: "item.id == '5e-srd-5.1:conditions.sight-rot'" }
key: penalty
by: 1

An action may involve other characters: a target, a helper. It declares each role under participants, and the app passes each role’s characters to startAction as views ({ id, values }), taken from their own computed sheets, like a link slot’s (rules-and-fields.md). The engine still computes only the actor.

# 5e-srd-5.1 rules/combat.yaml
- id: weapon-attack
params:
targetAc: { type: integer, optional: true } # for apps that pass no target
participants:
target:
max: 1
fields:
combat.ac: { type: integer }
conditions.prone: { type: boolean, default: false }
  • A role name is an identifier, not self. min defaults to 0 and max to unbounded, so a role is optional unless min says otherwise. fields is declared as a link slot’s ({ type, values?, default? } per field ID); a role without fields gets views of only id. An optional pack names the pack participants are expected to use, which only the build checks the fields against (else against the action’s own pack). Text: <actionKey>.participants.<role>.name (optional).
  • A change sent to a role (or target) may name a field, content or copy type of a pack the actor’s ruleset doesn’t load, qualified with that pack: it is passed on as written, as a change sent to a link is, and the participant’s own applyChanges checks it. The FitD crew’s incarceration roll gives the member going to prison a trauma this way (field: 'fitd-scoundrel:trauma.current', to: prisoner).
  • participants.<role> is the role’s list of views in input order, [] when none were given. A view is id plus the declared fields, nested at the dots (participants.target[0].combat.ac); a field the view lacks reads its default, else is absent (has()). Roll effects and result bands see participants too, as dyn: the running action’s roles, empty when it declares none. So the attacker’s rules can read the target’s conditions:
effects:
- target: { rolls: [attack.ranged, attack.spell.ranged] }
op: rollModifier
value: { kind: disadvantage }
when: >-
has(participants.target) && size(participants.target) > 0
&& participants.target[0].conditions.prone

Per participant. A roll, choice, changes or action step with each: <role> runs once per participant of the role, in input order, with participant bound to the current view and when evaluated for each. With none, it doesn’t run. Its paths gain the index (saves[1], saves[1]/check). Its result is the list of per-participant results (null where when skipped one); inside a later each step over the same role it is the current participant’s result, and inside the step itself the list of its earlier runs (absent in the first run), so a per-target choice can count the picks before it. outcome steps don’t take each.

A branch step with each sets its flags per participant: a later each step over the role reads the current participant’s as flags.<name>, and elsewhere a flag is the list (null where none was set). So a spell works out each target’s outcome once:

- id: degree
kind: branch
each: target
cases:
- when: true
flags:
outcome: "has(steps.save) ? indexOf(['critical-success', 'success', 'failure', 'critical-failure'], steps.save.result) : 2"
- id: burn
kind: changes
each: target
when: flags.outcome > 0
to: target
changes:
- { type: adjust, field: hp.current, by: '-steps.damage.total * [0, 0.5, 1, 2][flags.outcome]' }

Handoffs. An action step with each and as: participant runs on the participant’s own character instead: the action pauses with a handoff need naming the participant and the used action with its evaluated params. The app runs that action on the participant’s character, with its own packs, situational choices, policy and dice, and answers continueAction with the outcome. The step’s result is steps.<id>.result and .data. A handoff always pauses, whatever the policy. A handoff can pass participants on, the running character included: Fate’s attack hands the target defend with itself as the attacker, so a Riposte can hit back (participants: { attacker: '[character.id]' }). Fireball, damage rolled once and saved per target:

- id: saves
kind: action
each: target
when: has(item.save)
as: participant
use: saving-throw
params: { ability: item.save, dc: flags.dc }
- id: burn
kind: changes
each: target
when: has(steps.damage) && has(steps.saves)
to: target
changes:
- type: adjust
field: hp.current
split: { roll: damage, by: damage }
by: >-
steps.saves.result == 'success' ? -floor(max(0, part.total) / 2) : -max(0, part.total)
tags: [damage]

Changes for a participant. to: <role> (or to: target, which needs no declaration) sends a step’s changes to the role’s characters. Each proposed change names its participant in character: in an each step over the role, the current one; elsewhere, one copy per participant, in order. With no participants, a change is proposed once without character, as before. The changes name the actor’s own fields, as to: target changes do.

Kind Does Result
roll Rolls parts (one roll item each); roll effects matching the step’s tags add items, modify dice or bound the total; an optional bands set classifies it; with of, classifies an earlier roll again (below) steps.<id>.total, .dice, .band
choice Offers options from one source (below); recommend or prefer names the rules’ pick; repeat picks more than once steps.<id>.picked, .item, .self
allocate Spends a budget across fields, or hands out a list of values, one per field (below) steps.<id>.amounts
branch Runs the first case whose when is true (else else), sets its flags and runs its steps flags.<name>
changes Proposes changes for the app to apply, to: self (default), to: target, to: <role> or to: { link: <slot> }; release proposes held ones; content adds to a tagged one (below) —
action Runs another action (use, params, and source, participants and tags below), e.g. a panic check after a failed save; with as: participant, hands it off steps.<id>.result, .data
outcome Ends the action with result and data —

Every step may have when; a step whose when is false is skipped. Roll, choice, changes, action and branch steps may have each: <role> (Participants).

Classifying a roll again. A roll step with of: <step path> instead of parts rolls nothing: it takes an earlier roll of the action (its path as the log names it, relative to the action, so check/roll is the roll of the nested action step check) and classifies it by its own bands, in its own context. With each, it does so once per participant, with participant bound and that participant’s flags, so one roll is compared with each creature’s DC, its tallies included. Its result is the earlier roll’s (total, kept, tallies, …) with its own band. orc-2e’s Hide rolls Stealth once against every observer:

- { id: check, kind: action, use: check, params: { check: "'skills.stealth.mod'" } }
- id: dcs
kind: branch
each: observer
cases:
- when: true
flags: { dc: participant.perception.dc } # the `degree` set reads `flags.dc`
- { id: against, kind: roll, each: observer, of: check/roll, bands: degree }

Changes content adds. A changes step may carry tags (computed entries allowed, and those an action step adds count), and may have only tags: a place in a shared action that content adds to. A content item’s or template’s changes effect aimed at a matching tag proposes its changes after the step’s own whenever the step runs (per participant in an each step), while its when holds: a rune’s persistent damage on a Strike’s critical hit, a feat’s condition on a spell’s targets, a toggle the start of a turn ends.

# packs/orc/2e/rules/strikes.yaml, the Strike: a hit, and a critical hit.
- id: onHit
kind: changes
when: has(flags.hit) && flags.hit
to: target
tags: [strike.hit, { formula: "flags.crit ? ['strike.hit.critical'] : []" }]
# content/runes/flaming.yaml: 1d10 persistent fire damage on a critical hit with this weapon.
effects:
- target: { steps: [strike.hit.critical], from: self }
op: changes
value:
changes:
- type: applyEffect
source: "'conditions.persistent-damage'"
state: { damage: 1d10, damageType: "'fire'" }

Offering a follow-up. A proposed offerFollowUp names a follow-up of the content whose option or added changes propose it (or of its content type), else of the content defining the action, else of the rules, with params formulas the action evaluates. The action resolves it to the qualified follow-up and the content’s source and proposes it to the actor; applying it stores the follow-up as an ephemeral offer (rules-and-fields.md). What a field can’t show is offered so: which spell a cast paid for.

# content/features/sorcerer/blood-magic.yaml: blood magic after a gift spell cast from a slot.
followUps:
- { id: blood-magic, action: blood-magic }
effects:
- target: { steps: [spell.paid] }
op: changes
when: "action.item.id in sorcerer.gifts && (flags.mode == 'spontaneous' || flags.pooled)"
value:
changes: [{ type: offerFollowUp, followUp: blood-magic, params: { rank: flags.rank } }]

Action steps. An action step runs the used action with the same character, entry and source, and with params evaluated in its own context. Seven optional properties change that (with as: participant, only participants):

  • source, a formula giving a content ID (qualified against the action’s pack), runs use as computeSheet lists it for this character with that source: that content is item and the listing’s entry is self, so a monster’s granted bite runs its monster-attack as the app would. With source, use may also be the local ID of an action the content’s type or the content itself defines. Nothing listed (the action not available included) is unknown-action.

  • entry, a formula giving an instance ID (a choice step’s steps.weapon.picked), runs use as computeSheet lists it with that entry, and with that source when the step gives one too: that copy is self and its item item, as if the app had started the copy’s action. No such listing (no such copy, the copy doesn’t list the action, or its available is false) is unknown-action. A feat that is a Strike with a weapon the player picks picks the weapon, then runs that copy’s Strike (below).

  • participants maps the used action’s roles to formulas giving lists of participant IDs of this action, in any role, or of their views, mixed or not: each view is passed on by its id, so target: participants.target passes on every target, and participants.target[0] is as good as participants.target[0].id. A list of anything else is type-mismatch. The nested action reads each participant’s view as the app passed it, with its own declared fields, and its roles’ min and max apply. Without it, the nested action has no participants. A handoff may pass participants too, and may also name the running character by character.id (the file’s id); needs.run.participants lists the IDs by role, and the app passes their views to the run, as it would to startAction.

  • tags are added to the tags of every roll step and choice step of the used action, and of the actions it runs in turn. Roll effects, situational choices, added options and policy rules match them as the step’s own, so a shared test rolled for an attack is tagged attack too.

  • situational gives the used action, and the actions it runs in turn, values for situational choices (by ID, local to the action’s pack or qualified), each a formula evaluated here, in place of the running action’s, as if the app had selected them. Twin Feint’s target is off-guard to its second Strike: situational: { offGuardTarget: true }. Not with as: participant.

  • A params formula giving null leaves its parameter out: an optional one takes its default or stays absent, a required one is missing-param. A field parameter given a field parameter’s view (params.skillA) takes its id, so an optional parameter passes on as it came.

  • hold: true keeps what the used action proposes instead of proposing it: every change it (and the actions it runs) proposes is resolved as usual and held under the step, in order (with each, every run’s). The nested action’s own steps see its changes, as always; the parent’s later steps, a sheet: proposed one included, don’t until they are released. A later changes step’s release proposes held changes:

    Property Meaning
    steps hold action steps of this action (unknown-reference, kind step, at load otherwise), released in this order
    where Which to release: a condition with change bound to each held change as the action returns it (tags [] when it has none)
    combine true merges released adjust changes equal in everything but by, recipient included, into the first one, by summed

    Released changes are proposed before the step’s own changes (a step needs one of the two), each keeping the step path it was proposed at; those where leaves stay held for a later release, and changes never released are dropped. A step with release doesn’t take each. So a parent decides what its nested action’s changes become: orc-2e’s Exacting Strike drops the Strike’s count of the attack on a failure, and Double Slice combines its two Strikes’ damage for resistances and weaknesses:

    packs/orc/2e/content/feats/fighter/double-slice.yaml
    - id: strike1
    kind: action
    use: strike
    entry: steps.first.picked
    hold: true
    participants: { target: participants.target }
    - id: strike2
    kind: action
    use: strike
    entry: steps.second.picked
    hold: true
    params: { attack: 'min(3, turn.attacks + 1)' }
    participants: { target: participants.target }
    - id: deal
    kind: changes
    release: { steps: [strike1, strike2], combine: true }
    # content/feats/fighter/exacting-strike.yaml: the Strike tags its count `attack-count`
    - id: pass
    kind: changes
    release:
    steps: [strike]
    where: "steps.strike.result != 'failure' || !('attack-count' in change.tags)"

A Multiattack made of a creature’s own attacks, each against a target of its own:

- id: multiattack
participants:
target: { max: 3 }
steps:
- id: bite
kind: action
use: monster-attack
source: "'monsters.adult-red-dragon.bite'"
participants: { target: 'size(participants.target) > 0 ? [participants.target[0].id] : []' }
- id: claw1
kind: action
use: monster-attack
source: "'monsters.adult-red-dragon.claw'"
participants:
target: >-
size(participants.target) > 0
? [participants.target[min(1, size(participants.target) - 1)].id] : []
# …

orc-2e’s Double Slice: two Strikes, one with each melee weapon, the second at the first’s multiple attack penalty:

- id: double-slice
participants: { target: { max: 1 } }
steps:
- id: first
kind: choice
options: { copies: { type: weapon, where: 'self.equipped && item.range == 0' } }
- id: second
kind: choice
options:
copies:
type: weapon
where: 'self.equipped && item.range == 0 && self.id != steps.first.picked'
- id: strike1
kind: action
use: strike
entry: steps.first.picked
participants: { target: participants.target }
- id: strike2
kind: action
use: strike
entry: steps.second.picked
tags: [double-slice]
params: { attack: 'min(3, turn.attacks + 1)' }
participants: { target: participants.target }

Reading the proposed character. An action never applies its changes, so a step reads the character as given. A step with sheet: proposed reads it with every change the action has proposed so far to: self applied, in order: its fields, character, self and item (the action’s own copy there), a choice step’s candidates, an allocate step’s fields and a roll’s parts and effects. It is computed afresh before the step, when included, and before each pick of a choice step; nothing is kept between calls. Changes for a role, a target or a link aren’t applied, nor are follow-ups. A batch that fails is proposed-changes-failed, a pack bug. 5E’s starting hit points count the Constitution the race just raised:

- id: finish
kind: changes
sheet: proposed
changes:
- { type: set, field: hp.current, value: hp.max }
- { type: finishCreation }

A roll part is exactly one of:

  • dice: a dice formula;
  • value: a flat formula;
  • field: a formula that evaluates to a field ID. The item’s value is that field’s value and its source is that field (Strength or Dexterity for a finesse weapon).

A value that is a single field reference gets that field as its source. Otherwise the default source is the item, or the rules, the action belongs to.

A choice step’s options come from one source:

  • A written list [{ id, when?, changes?, to?, flags? }]: each option is named <actionKey>.options.<id>.name. An option’s changes are proposed when it is picked, to its to (default self); its flags are set first.
  • Content, { content: { type, where? } }: the ruleset’s content of a type, by qualified ID. Each option is a qualified content ID, named by the item’s name.
  • The character’s copies, { copies: { type, where? } }: inventory then features. Each option is an instance ID, named by the copy’s item name.
  • Pending choices, { pending: { entry? } }: the choices still pending on the proposed character, in pending’s order (each item’s own choices before those of what it grants, each followed by those of what it picked), one per pick, each with its eligible options as listOptions gives them, then #done. Each pick proposes the choose; the step goes on until nothing more can be picked, so an action asks what the content it adds brings without naming it. entry (an instance ID formula) asks only that copy’s choices. The app names each pick with the choice’s own text (needs.choice).

In a query source’s where, item (and for copies self) is the candidate, picks is how many times the step has already picked it, and the action’s own item and copy are action.item and action.self. A written option’s when sees picks too.

Options that set flags. A written option, or one content adds, may set the action’s flags when picked, as a branch case does. They are set before the option’s changes, so those changes and every later step read them; a flag already set is replaced. An added option may set only flags its action sets elsewhere (load error otherwise), so the action gives each a default in an early branch:

# The action: a default in its first branch.
- id: start
kind: branch
cases:
- when: true
flags: { slot: params.slot, castAbility: "'class'" }
# A trait's option on the `spell-cost` step casts at 2nd level with Charisma.
- target: { choices: [spell-cost] }
op: option
when: "action.item.id == '5e-srd-5.1:spells.hellish-rebuke' && !self.hellishRebukeUsed"
value:
id: hellish-rebuke
flags: { slot: 2, castAbility: "'cha'" }
changes:
- { type: setState, entry: self, key: hellishRebukeUsed, value: true }
Property Meaning
tags The step’s tags: content adds options to it, and policy rules match it, by tag
recommend A formula giving the recommended option ID; it may read options, the source’s offered IDs, so a roll can pick one
prefer Content and copies: a number per candidate; without recommend, the highest is recommended; with neither, nothing is (0100)
random A condition: while it holds, each pick’s recommendation is drawn uniformly with a die as large as the options (0108)
repeat { max, min? }: pick up to max times; after min picks the reserved #done is offered last and ends the step
changes Proposed after every pick, to: self; picked is { id } (plus item, and self for copies); entry: picked is the copy

Over pending choices recommend also sees choice ({ path, owner, id }, the choice asked), and without it the choice’s own recommendation applies; picked is { id, path }, and the result is steps.<id>.picked and .paths, both lists. 5E’s level-up asks what the new level brings, a subclass at 3rd level, an Ability Score Improvement at 4th:

- id: picks
kind: choice
options: { pending: {} }

Options added by content. On a step with tags, the character’s content adds options with option effects aimed at { choices: [<tag>] } (effects.md), offered after the source’s options as <content id>#<option id> and named <content id>.options.<option id>.name. They are never recommended, so an automatic run keeps the rules’ choice. A picked option’s own changes are proposed before the step’s. Pushing yourself in FitD costs 2 stress, or Battleborn’s special armor:

# rules/rolls.yaml, action-roll
- id: pushCost
kind: choice # actions.action-roll.steps.pushCost.prompt
tags: [push-cost]
when: "situational.bonusDie == 'push' || situational.pushEffect"
options:
- id: stress # actions.action-roll.options.stress.name
changes:
- type: adjust
field: stress.current
by: "(situational.bonusDie == 'push' ? 2 : 0) + (situational.pushEffect ? 2 : 0)"
recommend: "'stress'"
# content/special-abilities/battleborn.yaml: special-abilities.battleborn.options.special-armor.name
effects:
- target: { choices: [push-cost] }
op: option
when: '!armor.special'
stackKey: special-armor
value:
id: special-armor
changes:
- { type: set, field: armor.special, value: true }

steps.<id>.picked is the option ID, or with repeat the list of picks (#done left out). Over content or copies without repeat, steps.<id>.item is the picked item and, for copies, steps.<id>.self the picked copy. A step with nothing to offer is skipped. 5E’s long rest regains spent hit dice up to half the total level, one die per pick, from the classes the player chooses:

- id: hitDice
kind: choice # actions.long-rest.steps.hitDice.prompt
options:
copies: { type: class, where: self.hitDiceSpent > picks }
repeat: { max: 'max(1, floor(level.total / 2))' }
prefer: self.hitDiceSpent - picks # recommend the class with the most dice still spent
changes:
- { type: adjustState, entry: picked, key: hitDiceSpent, by: -1 }

Random picks. While a choice step’s random holds, the engine draws each pick’s recommendation: one die with as many sides as the options the source offers, logged and paused for like any die (needs.draw.among lists what its faces name). A guided and a random creation start share one action this way, and an allocate step’s random draws its amounts (Allocate steps). A creation action can pick from the ruleset’s content and read the pick later:

- id: class
kind: choice
options: { content: { type: class } }
random: params.random # a d12 among the twelve classes
- id: strength
kind: roll
parts: [{ dice: steps.class.item.strength }]

An allocate step hands amounts to several fields at once. Its fields is a query over fields ({ type: field, where? }, each field seen as item: id, type, tags); the step offers the ruleset’s integer input fields that match, in field order. The query also sees the action (steps, params, flags, …), with the action’s own item and copy as action.item and action.self. It works in one of two ways:

  • A budget (budget, an integer formula): the amounts’ total cost must be the budget (spend: all, the default) or at most the budget (spend: upTo). An amount may never take the field’s input past its max; limit, a formula with field ({ id, value }, the computed value before the step) bound, may narrow that further. cost is what an amount costs, a formula with field and amount bound; without it, the amount itself. The step proposes one adjust per field with a non-zero amount.
  • Values (values, a list of integer formulas): each value goes to a different field, and every value is used exactly once. The step proposes one set per field given a value.

recommend maps field IDs to integer formulas; it is used only when it is a valid answer. random (a condition) recommends a random answer instead: each value, or each unit of the budget, goes to a field drawn uniformly among those that can still take it, with a die per draw (engine-actions.md). Fate’s random pyramid hands its ten ratings to any ten skills, homebrew ones included:

- id: pyramid
kind: allocate
fields: { type: field, where: "'skill' in item.tags" }
values: [4, 3, 3, 2, 2, 2, 1, 1, 1, 1]
random: params.random

Under an automatic policy the engine takes it, and without one the step pauses: the app answers with amounts, a map of field ID to amount (fields left out get nothing). An answer that doesn’t fit (a negative amount, a field not offered, an amount over its limit, a cost that breaks the budget, values not each used once) is answer-mismatch. steps.<id>.amounts is the answer, by qualified field ID. A step whose query matches no field is skipped.

FitD’s four creation dots, none taking an action above 2 with the playbook’s own dots (which the proposed sheet shows), and 5E’s point buy and standard array:

- id: dots
kind: allocate # actions.create-scoundrel.steps.dots.prompt
sheet: proposed
fields: { type: field, where: "'rating' in item.tags" }
budget: 4
limit: max(0, 2 - field.value)
- id: pointBuy
kind: allocate
sheet: proposed # shows the 8s an earlier step set
fields: { type: field, where: "'ability-base' in item.tags" }
budget: 27
spend: upTo
limit: 7 # 8 to 15, after setting each score to 8
cost: '[0, 1, 2, 3, 4, 5, 7, 9][amount]'
- id: standardArray
kind: allocate
fields: { type: field, where: "'ability-base' in item.tags" }
values: [15, 14, 13, 12, 10, 8]

rollModifier effects (data) change how a roll is made:

  • advantage / disadvantage: roll one extra die for the roll’s main dice item (its first dice part) and keep the better or worse ones. “Better” follows the step’s better: high (the default) or low (roll-under games).

  • noAdvantage / noDisadvantage: once the advantage and disadvantage sources are counted, set the count of that kind to 0, before settings.rollModifiers.advantage combines them. The player’s situational advantage is a source like any other, so it is removed too. 5E’s Elusive: no attack roll against the rogue has advantage, and disadvantage still applies.

  • reroll (below, once, count), minimumFace (value), explode (at). reroll’s optional count (an integer formula) rerolls at most that many of the initial dice matching below, lowest faces first (earliest first among equal faces); without it, every matching die. The rerolled dice keep their order: their new faces are requested die by die, as for any reroll. The roll item’s rerollOnce modifier carries the count. 5E’s Empowered Spell rerolls up to the Charisma modifier of the damage dice (minimum one): { kind: reroll, below: 'floor(maxOf(dieOf(roll.item.damage)) / 2)', once: true, count: max(1, abilities.cha.mod) }.

  • multiplyDice (factor) multiplies the dice count of every dice item in the roll, including items added by other effects. This is how a critical hit doubles all damage dice. Dice added by an effect with multiplied: false are left alone (effects.md): 5E’s Savage Attacks and Brutal Critical roll their extra dice once.

  • multiply (factor) multiplies the value of every item once the roll is rolled, dice and flat alike, including items added by other effects: a critical hit that doubles the damage, not the dice (orc-2e, MÖRK BORG). Each item’s value (a dice item’s rolled value, a flat item’s value) is multiplied by every counted multiply factor in turn, an integer result rounded by the effect’s rounding, else settings.rounding, as a multiply effect is. Faces are unchanged; the item records factor (the product). The total is the sum of the multiplied items; atLeast and atMost bound it afterwards, and a proposed change’s split sums the multiplied items. Tallies aren’t items and aren’t multiplied. multiply modifiers stack by key, as other roll effects do.

    Both leave alone the items marked multiplied: false: those an add effect with multiplied: false adds, and those of a roll part that says multiplied: false. orc-2e’s deadly die is added after doubling:

    - target: { rolls: [damage.strike] }
    op: rollModifier
    value: { kind: multiply, factor: 2 }
    when: has(flags.crit) && flags.crit
    - target: { rolls: [damage.strike], from: self }
    op: add
    value: dieOf(item.deadly)
    multiplied: false
    when: has(flags.crit) && flags.crit && has(item.deadly)
  • stepDie (steps, an integer formula) changes the size of the dice from the roll’s own parts: each moves steps along d4, d6, d8, d10, d12 (negative for smaller), stopping at either end; a die of another size (a d20, a d3, Fate dice, dNz) is left alone, and so are the dice effects add. With a parts target (effects.md) only the matching parts’ dice move. The counted modifiers that reach an item add their steps (they stack by key, compared by steps), before pool dice and advantage, so those dice are the new size. The item records steppedFrom, its sides before. orc-2e’s Healing Hands: { kind: stepDie, steps: 1 } on heal’s rolls.

  • setFaces (faces, a list of integer formulas) gives a dice item its first faces instead of rolling them: the roll’s main dice item, or with a parts target the first dice item of a matching part, after every other modifier. Faces past the item’s count are ignored; one not on the die is invalid-face. Extra dice (advantage, rerolls, explosions) are still rolled or asked for, and a paused roll asks only for the faces not given; a roll whose every initial face is given doesn’t pause. Each given face is marked set: true and isn’t in the log entry’s faces (what was supplied). Of several counted setFaces on one item, the first in effect order applies. orc-2e’s Devise a Stratagem: the stratagem’s Strike uses the d20 rolled earlier, { kind: setFaces, faces: [self.roll] }.

How opposing modifiers combine (5E’s advantage and disadvantage cancelling) is a game setting; see Advantage and disadvantage together.

A rollModifier of kind poolDice adds count dice to a dice part of the roll (the part named by part, or the step’s first dice part), before its keep/drop modifiers:

- target: { rolls: [action-roll] }
op: rollModifier
value: { kind: poolDice, count: 1 }
when: situational.push
  • The added dice are the part’s die type. In the roll item, each added face records its source; faces without one belong to the item’s own source.
  • ifEmpty: a dice part may name what to roll when it ends up with no dice after pool dice are added. FitD rolls 2d6kl1 for an empty pool: { dice: '(params.rating.value)d6kh1', ifEmpty: 2d6kl1 }.
  • The roll result’s empty (steps.<id>.empty, roll.empty in bands) is true when ifEmpty was rolled. FitD uses it to rule out criticals.

settings.rollModifiers.advantage says how opposing roll modifiers combine:

  • cancel (the default; 5E, Mothership): advantage and disadvantage together cancel, and any number of sources of one kind give one extra die.
  • net: the difference between the number of sources decides the direction and how many extra dice.
  • stack: every source adds a die; both kinds together is an error in the pack.

Advantage keeps the better dice per the roll step’s better. That doesn’t change how sources net out. The action log records every source, including cancelled ones. A noAdvantage or noDisadvantage modifier removes its kind before they combine (above): with cancel, advantage and disadvantage plus noAdvantage is disadvantage.

A parameter of type id with of: field lets the app pass a field. where restricts which fields, as a query over each field’s id, type and tags:

params:
stat: { type: id, of: field, where: "'stat' in item.tags" }
  • The action reads the field’s computed value as params.stat.value, its qualified ID as params.stat.id, its type as params.stat.type and its tags as params.stat.tags ([] if none; computed ones evaluated on the sheet): the same view its where sees as item. An action can branch on what kind of field it was given ('rating.prowess' in params.rating.tags) instead of a list of field IDs.
  • The type checker knows the union of the allowed fields’ types for .value; .id and .type are strings and .tags a list of strings.
  • A field outside the set is invalid-option when the action starts.
  • Roll parts’ field: stays available for picking a field by formula.

A content parameter (type: id, of: <content type>) may have where too: a condition with item the candidate item (its own fields, no templates), the character’s fields, and the action’s own item and copy as action.item and action.self. The app passes the item’s ID, qualified or local to the action’s pack; an ID that isn’t an item of the type, or whose item doesn’t match, is invalid-option when the action starts. Formulas read params.<name> as the qualified ID. 5E’s Stillness of Mind names the condition to end:

params:
condition:
type: id
of: condition
where: "item.id in ['5e-srd-5.1:conditions.charmed', '5e-srd-5.1:conditions.frightened']"

Each roll, choice and allocate step is a checkpoint, except a roll step whose built roll has no dice items: it never pauses, and no policy applies to it. Following the app’s checkpoint policy (action-run.schema.json#/$defs/policy), the engine either continues by itself (built-in or app-supplied dice; the recommended option) or pauses:

policy:
dice: auto # default for roll checkpoints
choices: pause # default for choice checkpoints
allocations: auto # default for allocate checkpoints
rules: # first match wins
- { kind: roll, tags: [attack], then: pause } # "wait on attack dice so I can animate them"
- { kind: choice, tags: [push-cost], then: pause } # "ask how every push is paid for"
  • By default the engine rolls with its built-in random number generator.
  • The app may swap the dice function (its own randomness, a server roller, a seeded roller for tests). The dice function is a language-level callback, not data.
  • When the app supplies dice, it supplies individual die faces, never totals. The engine does all the dice math: keep, drop, explode, rerolls and critical hits. Extra dice that are needed (explosions, rerolls) cause another pause or another call to the dice function.
  • A paused result (status: paused) says what it needs (dice with count/sides; a choice with options and recommended, #done ending a repeated choice; an allocation with its fields, budget or values; or a handoff with participant and run), at which step, and which action defines that step, and carries state. The state is plain JSON, so the app can continue minutes later, from another request, or on another machine, by passing it to continueAction. The shape of state is PROVISIONAL (engine-defined).
  • Every action keeps a log of every die face, choice, allocation and handoff outcome, which step and action it belongs to, and who supplied it (engine or app). Each pick of a repeated choice step is its own entry, numbered by index; a handoff’s entry names its participant and outcome. Replaying an action from its inputs and answers MUST give the same result: startAction’s answers takes the answers, one per pause, as continueAction does.
  • Reactions after a roll (Lucky, Inspiration, pushing a roll) are choice checkpoints.
  • A finished action (status: finished) returns its outcome, proposedChanges ({ to, character?, step, change }, fully resolved, with the path of the changes step that proposed each) and log. The engine never applies changes itself: the app applies to: self changes with applyChanges, applies a change for a role or a link to the character named by character, and routes a to: target change without one to the right character.

Packs are untrusted, so every implementation enforces the safety limits: maxExplosions, maxRerolls, maxDicePerItem, maxFormulaDepth, maxFormulaLength, maxChoiceDepth, maxActionSteps, maxActionDepth, maxExtendsDepth and maxOverflowChain. Reaching one is the error safety-limit-exceeded. The values are PROVISIONAL.