Skip to content

Content

Each game defines its own content types in data (content-type.schema.json), declared under contentTypes in a rules document. Mothership does not inherit 5E’s idea of a spell. A type name is an identifier (weapon, fightingStyle), and the names template, field, tag, string and contentType are reserved.

A content type’s interface has four parts:

Part Declared as Answers
Fields fields: { <name>: valueDecl } What is it? (damage 1d8, weight 3)
Effects effects on each item How does it change the character, and when?
Actions actions on the type (every item gets them) and on items What rolls can it make?
State state: { <name>: valueDecl } What’s tracked on your copy? (charges, shots, level)

A valueDecl has type (a value type), plus values for enums, of for id/set, members for an id or set of tag (rules-and-fields.md), required, default, and min/max.

Item fields are what an item’s author writes, never what changes in play (that is state), so an app can build a custom-item form from them; each has a name and an optional hint in the pack’s text (types.<type>.fields.<field>.name; i18n.md).

An item field’s default applies when an item doesn’t set the field (or, on a copy, when none of its templates does): item.<field> and copy.fields.<field> read it as if the item had written it (a string on an integer, decimal, boolean or dice field is a formula, as an item’s own value is). An id or set item field of a content type or of field holds qualified IDs: a value resolves in the item’s pack, a default in the content type’s pack, so a stunt’s skill: skills.lore equals a field parameter’s params.skill.id.

Two flags control where copies live:

  • carried: true: copies go in the character’s inventory. Otherwise they go in features.
  • equippable: true: copies have the core equipped flag (self.equipped).

A content type may say how many copies a character holds:

packs/5e/srd-5.1/rules/content-types.yaml
contentTypes:
class:
copies: { perItem: 1 } # each class at most once (multiclassing adds another)
race:
copies: { max: 1 } # one race
  • max (an integer ≥ 0): at most this many copies of the type. perItem (an integer ≥ 1): at most this many copies of any one item of the type. Without copies, or without a key, there is no limit.
  • Each is an integer, which an app reads from the ruleset (contentTypes.<type>.copies) without evaluating anything, or an integer formula over the character’s fields, worked out on the computed sheet, so a field content raises lifts the limit (a variant rule allowing a second class: copies: { max: classes.max }). computeSheet’s copyLimits gives every limit’s value; a formula that fails is no limit, with a formula-error.
  • What counts: the character’s copies whose item is of the type, in features and inventory alike, standard copies included, whatever their quantity (ten arrows are one copy). Picked content, granted content, templates and applied entries are not copies and don’t count: a pick is limited by its choice’s pick.
  • max: 0 says the type is never held as a copy, only by picks (a FitD playbook, picked in a rules-level choice).
  • Past a limit the sheet has the rules issue too-many-copies (engine-core.md), and strict mode rejects the batch as for any issue. computeSheet lists each limited type with its count and copies (copyLimits), so an app offers Replace instead of Add. A replace is removeItem then addItem in one batch (engine-core.md).
  • The limit holds always, created or not. A creation-time “needs one” stays a constraint (!character.creating || countCopies(copy.type == 'class') > 0), as does a limit across types (MÖRK BORG’s class or companion).
  • Content types can’t be overridden, so a homebrew or a house rule can’t lift a limit; a table that allows more runs without strict mode and ignores the issue.

Text: types.<type>.name, and optionally a name for each state key and computed value (types.<type>.state.<key>.name, types.<type>.computed.<name>.name). A type may also declare named texts (texts: [material, trigger]): extra text each item may have, looked up as <contentId>.texts.<name>, none required (i18n.md). A type with copies may word its limit as types.<type>.copies (optional; max and count as arguments).

A content type may declare effects, followUps and grants that every item of the type has, before the item’s own:

  • item and self are bound as for the item’s effects, and the source is the item, not the type.
  • An item switches off a type effect by ID with suppress: [<effect id>]; the 5E shield suppresses armor’s base-ac and wearing-armor.
  • Content types can’t be overridden by other packs (only items can).

A content item (content-item.schema.json) is mechanics only. Its text lives in i18n under <id>.name and <id>.description.

Property Meaning
id, type Local ID (matching the file path) and content type
override Instead of id/type: patch another pack’s item (packs.md)
extends Permanent variant of another item
tags Free-form tags for queries (martial, sword, finesse)
fields Values for the type’s item fields: literals or formulas. They MUST match the declared types; undeclared names and missing required fields are errors.
state Extra per-copy state keys beyond the type’s
effects effects.md
followUps What the rules call for after a change while the character has the item (rules-and-fields.md)
actions Inline actions, or { use: <rules action>, params }; scope: applied offers one on the character the item is applied to
choices See below
grants { item, when, scope? }: other content made active while this item is active (class features at level N); scope: applied while it is applied (below); item may be a formula
prerequisites { id, when }: conditions for having the item
immuneTo { id, items | query, when?, scope? }: applied content the character can’t be affected by while the item is active (below)
reapply { match?, state }: what applying the item again does, replacing its type’s (below)
appliesTo Templates only
removeTags Templates only: tags the copy’s item doesn’t have (below)
keepsTemplates Templates kept on the character’s copies while the item is active (below)
keepsFields Item fields the templates it keeps leave alone (below)
repeatable true: exempt from exclusivity groups, so several choices of one can pick it (below)
deprecated { since, replacedBy? }

Content is active on a character, meaning its effects, grants and choices apply, when it is:

  • in features;
  • in inventory (effects usually add when: self.equipped);
  • picked in a choice, unless the choice says activate: false (below);
  • granted by active content whose grant when holds (a grant with scope: applied by an active, unblocked applied entry of the content, as applied content: below);
  • the source of an active applied entry;
  • a template applied to an active copy, or kept on it by active content (below); or
  • a standard copy, which every character holds without the file listing it.

Whether an effect applies to the holder or to the character the content is applied to is the effect’s scope (effects.md).

Applied content that includes other content. A grant (grants on a content type or item) may say scope:

scope The grant applies while The granted content’s activation
holder (default) the content is held holder; self the owner copy
applied the content is the source of an active, unblocked applied entry applied; self the granting entry

The granted content is then applied content too, for as long as the entry: its scope: applied effects, immunities, grants and actions apply, as if it were the source of an entry of its own. Immunities block it as they would such an entry (reported immune with the granting entry); the entry that grants it stays. A condition that includes another grants it:

packs/orc/2e/content/conditions/grabbed.yaml
grants:
- { item: conditions.off-guard, scope: applied }
- { item: conditions.immobilized, scope: applied }

A grant’s state. A grant may say state: values the granted content sees as self.<key>, in place of the granting entry’s (an applied grant) or the owner copy’s (a holder grant). Each key must be a state key the granted content declares (unknown-state-key at load), and each value is a formula of the key’s type, evaluated as the grant’s when is: an activation condition reading self (the granting entry or copy, as it is), item and input fields. A value that fails leaves the key as it was, with a formula-error. So applied content that includes a valued condition gives the value, and an affliction gives each stage’s conditions:

packs/orc/2e/content/afflictions/spider-venom.yaml
grants:
- { item: conditions.sickened, scope: applied, when: self.stage == 1, state: { value: 1 } }
- { item: conditions.clumsy, scope: applied, when: self.stage == 2, state: { value: 1 } }
- { item: conditions.sickened, scope: applied, when: self.stage == 2, state: { value: 2 } }

The granted content’s self keeps the entry’s id and its other keys; its effects, actions and nested grants see the values, and nothing is saved.

A grant’s item from a formula. A grant’s item may be { formula }: a string formula evaluated as the grant’s when is (an activation condition: self, item and input fields) to a content ID, qualified in the grant’s pack as a literal ID is. An empty string grants nothing; an ID that names no content grants nothing, with the sheet error unknown-id (data: id, where). So content grants what the player named, as a spell picked for the day:

packs/orc/2e/content/feats/sorcerer/occult-evolution.yaml
grants:
- { item: { formula: sorcerer.evolutionSpell }, when: "sorcerer.evolutionSpell != ''" }

The same content reached twice (two conditions granting off-guard, or off-guard applied as well) is two activations, as everywhere; stack keys keep its effects from counting twice where the game says they don’t. character.applied stays the file’s entries. A holder grant of applied content is not followed, as before.

Applying content again. A content type, or an item (replacing its type’s), may say what an applyEffect of it does while the character already has it applied:

contentTypes:
condition:
state:
value: { type: integer, default: 1, min: 0 }
reapply: { state: { value: highest } } # frightened 1 on frightened 2 stays frightened 2
# content/conditions/persistent-damage.yaml: one entry per damage type, the higher damage
reapply:
match: [damageType]
state: { damage: highest }

state names, per state key, how the existing entry’s value and the new one merge: highest, lowest, add (numeric keys) or replace (the new value). match names state keys that must be equal as well. Every key must be a declared state key of the content (unknown-state-key at load; add on a key that isn’t a number is wrong-type). applyChanges merges instead of adding an entry (engine-core.md).

Actions of applied content. An action on content (the type’s or the item’s, inline or use) has a scope too: holder (the default) offers it to a character who has the content active, with its copy as self; applied offers it for each active, unblocked applied entry whose source is the content, with the entry’s ID as its entry and the entry (id and its state keys) as self. A potion that lets its drinker cast a spell for an hour keeps the cast on the applied potion, since drinking spent the copy:

packs/5e/srd-5.1/content/magic-items/potion-of-animal-friendship.yaml
actions:
- use: drink-potion # holder: spends the copy and applies the potion
- id: animal-friendship
scope: applied # offered while the potion is applied, at will
tags: [magic-item, spell]
participants:
target: {}
steps:
- id: power
kind: action
use: item-power
params: { save: "'wis'", dc: 13, condition: "'conditions.charmed'" }
participants: { target: participants.target }

A shared action in the rules’ actions has no scope (a schema error): the use that lists it does.

An active applied entry is blocked instead, and not active, when its source matches an active immunity. immuneTo on an item lists them:

packs/5e/srd-5.1/content/features/monk/purity-of-body.yaml
immuneTo:
- { id: poisoned, items: [conditions.poisoned] }
# packs/5e/srd-5.1/content/features/barbarian/mindless-rage.yaml
immuneTo:
- id: charmed-and-frightened
items: [conditions.charmed, conditions.frightened]
when: rage.active
  • items lists content by ID; query ({ type, where }) matches it by a rule, with the candidate as item. Exactly one of the two.

  • when, and the query’s where, are activation conditions: they read self, item, character and raw input values, never derived fields.

  • The query’s where also reads entry, the candidate applied entry (id and its state keys, with their defaults), so it can match by who applied a condition. character.applied lists the saved entries raw ({ id, source, active, state }, blocking not reflected), so a when can hold only while some content isn’t applied:

    packs/5e/srd-5.1/content/features/druid/natures-ward.yaml
    immuneTo:
    - id: charmed-and-frightened-by-elementals-and-fey
    query:
    type: condition
    where: >-
    item.id in ['5e-srd-5.1:conditions.charmed', '5e-srd-5.1:conditions.frightened']
    && has(entry.by) && entry.by in ['elemental', 'fey']
    # packs/5e/srd-5.1/content/features/paladin/aura-of-courage.yaml
    immuneTo:
    - id: frightened
    items: [conditions.frightened]
    when: >-
    countList(character.applied,
    x.active && x.source == '5e-srd-5.1:conditions.unconscious') == 0
  • scope, as for effects: holder (the default) while the character has the item; applied while the item is the source of an active, unblocked applied entry (petrified makes its target immune to poison).

  • A blocked entry stays in the file and is reported as the rules issue immune. An applied entry’s immunity blocks matching entries before and after it; between entries that block each other, the earlier wins, and a blocked entry’s immunities block nothing (engine-core.md).

A choice’s and a grant’s when, like every activation condition, read input fields only: content is activated before any effect computes, so what it adds can’t depend on a value it might change. When a choice matters only for some computed states (a dragonblood’s Draconic Resistance asks for a damage type only for a physical exemplar), make it optional (required: false) and let its options’ effects test the computed value; when a grant is harmless twice (a feat the heritage may already give), grant it unconditionally.

The character’s copy of an item (an entry) has:

  • core properties: id, item, templates, equipped, quantity;
  • state values for keys declared by the content type, by the item, or by its templates;
  • choices.

Formulas see the copy as self: self.equipped, self.level, self.ignited. State keys MUST NOT reuse core property names.

A copy named by its state: a content type or an item may say nameFrom: <state key>, a string state key the copy has (declared by the type or the item). While the copy’s value of the key is a non-empty string (not only spaces), it is the copy’s name: apps show it in place of the item’s name, in the sheet’s lists and as a choice’s copy option (a faction the GM named, a FitD harm’s “Broken leg”). An item’s nameFrom replaces its type’s. computeSheet gives the names in copyNames (engine-core.md). The value is saved text the player or GM wrote, like a character’s name, not pack text, so it has no translation key. It changes nothing calculated. The validator reports a nameFrom that names no string state key.

contentTypes:
faction:
nameFrom: label
state:
label: { type: string, default: '' }

A state key’s default may be a formula when the key is numeric, boolean or dice, and its min and max are always formulas: shots: { type: integer, min: 0, max: item.magazine, default: item.magazine }. For string, enum and id keys a default is always a literal.

  • Roots: item, the copy’s item with its templates (closed, as for item field values). No self and no character functions.
  • Fields: input fields only (saved value or default), as for activation conditions, so a state formula never depends on effects.
  • Types: the default has the key’s type; min and max are numbers. An integer key’s decimal bound is compared as is.
  • When: a formula default is evaluated when the copy is added (addItem), or, for a template’s own keys, when the template is applied (applyTemplate), checked against the bounds (out-of-range), and written into the character file, because saved state must be explicit. setState and adjustState evaluate both bounds for the target copy, with its current templates, right before the range check. Any copy whose key has no saved value (an unsaved standard copy; a copy written by hand, or one a kept template gives the key) has its formula default evaluated when the sheet is computed, the same way, with its templates; an unsaved standard copy’s are written into the file when a change first saves the copy, and a saved copy’s file stays as written.

The build and loadRuleset check all three like any other formula (formula-error).

A content type, an item or a template may declare computed values: numbers, flags or strings worked out for each copy from the character, never saved. A FitD cohort’s quality and scale, or a fine pistol’s quality, are computed values.

contentTypes:
cohort:
fields:
kind: { type: enum, values: [gang, expert, vehicle], required: true }
computed:
quality: { type: integer, formula: "item.kind == 'gang' ? tier : tier + 1" }
scale: { type: integer, formula: "item.kind == 'gang' ? tier : 0" }
  • Declaring: computed: { <name>: { type, formula } }, with type integer, decimal, boolean or string. An item’s (and a template’s) computed adds names or replaces the type’s by name. A name must not reuse a state key of the copy or a core property (id, item, templates, equipped, quantity); the build reports it.
  • Evaluating: after every field, for each copy (features, then inventory, in file order). The formula sees item (the copy’s item with its templates), self (the copy, with the computed values declared before it), every field, character, links and the character functions. It resolves names in the pack that declared it.
  • Reading: self.<name>, in the copy’s own actions (including those it reaches through use, and their available) and its follow-ups, and in the copy’s later computed values: a computed value reads those declared before it, in declaration order (the type’s, then the item’s and each template’s, a replacement keeping the replaced name’s place). Later names, and its own, are unknown-name there. Field formulas, effects (roll effects included), constraints, activation conditions, state formulas and a choice step’s candidate copies can’t read them: there they are unknown-name at load, though self is open. Since they are computed after everything they could feed, and each reads only earlier ones, they can’t loop. An earlier value that failed is absent (has(self.<name>) is false), so a later value reading it fails too, with its own formula-error. The copy aggregates (countCopies, sumCopies, maxCopies, minCopies) don’t see them.
  • Output: computeSheet’s copies maps each copy’s instance ID to its computed values. A formula that fails, or gives a value of another type, gives no value for that name and a formula-error in errors (data: entry, name, cause).

A template is content with the built-in type template that is applied on top of another item during play, such as a +1 weapon or Flame Tongue.

id: magic-items.weapon-plus-one
type: template
appliesTo: { type: weapon }
effects:
- target: { rolls: [attack, damage], from: self }
op: add
value: 1
  • appliesTo is a query, so one template works on every matching item, homebrew included. Applying a template to an item that does not match is a runtime error (template-not-applicable).
  • appliesTo may list several queries: the template applies to an item any of them matches, so one curse fits a ring, a staff and a wand (appliesTo: [{ type: worn }, { type: staff }, { type: wand }]). Its item fields are then the template fields and those every listed type declares, and its formulas see item typed as the first type’s.
  • Templates may add state (Flame Tongue’s ignited), effects, actions, choices and grants.
  • In a template, formulas see the base item as base and the combined item as item. Its field values also see prior: the item as merged before this template, the base item and the copy’s earlier templates, so templates build on each other (a rune adds its price, price: '(has(prior.price) ? prior.price : 0) + 500'). For the first template prior is the base item.
  • Removing tags. A template may give removeTags: the copy’s item.tags are the base item’s, then for each template in order its removeTags taken away and its tags added, each once, so a later template may add a removed tag again. Only templates may give it.
  • Template fields. A template’s fields may set the template fields the rules declare (templateFields: { <name>: valueDecl }, shared by every template), and any field of the content type its appliesTo names; anything else is a build error, as on items. Its values are read as an item’s own are (integer, decimal, boolean and dice values are formulas over input fields), with base bound to the base item, in the template’s pack.
    • The copy’s item (and copy.fields) is the base item’s fields, then each template’s in the copy’s order, a later value replacing an earlier one with the same name: a Sun Blade’s damageType: radiant replaces the longsword’s slashing.
    • A template removes a field with null: the field is absent from the copy’s item and copy.fields (has() is false; the declaration’s default doesn’t apply), unless a later template sets it again. Only templates may give null; on any other item it fails the schema. Formulas reading such a field should guard it with has(), as for any field without a default: 5E’s Mithral armor gives strength: null, so mithral plate has no Strength requirement.
    • Every item’s item has each template field its content type doesn’t declare (a type’s own field wins by name): a template’s value, else the declaration’s default, else it is absent. So a plain arrow reads item.attackBonus as 0 when the declaration’s default is 0.
    • Template fields merge across packs by name. A name declared again must have the same type (and of and values), else the load error template-field-mismatch; the later declaration replaces the earlier.
packs/5e/srd-5.1/rules/magic-items.yaml
templateFields:
rarity: { type: enum, values: [common, uncommon, rare, very-rare, legendary, artifact] }
requiresAttunement: { type: boolean, default: false }
maxCharges: { type: integer, min: 0 }
# packs/5e/srd-5.1/content/magic-items/sun-blade.yaml
fields: { damageType: radiant, rarity: rare, requiresAttunement: true, attackBonus: 2 }
  • A template’s name is a translation with a placeholder, "{base} +1" ({base} is the base item’s translated name), kept in i18n like all text.
  • A copy’s templates apply in order. Applying or removing one is a change like any other (applyTemplate, removeTemplate), and listOptions tells the app which copies a template may apply to.

A content item may keep templates on the character’s copies while it is active:

# packs/orc/2e/content/features/monk/powerful-fist.yaml: the fist deals 1d6
keepsTemplates:
- template: templates.powerful-fist
copies: { type: weapon, where: "item.id == 'orc-2e:weapons.fist'" }
  • template names a template (unknown-reference at load otherwise). copies is a query over the copy’s base item, read only item (resolved by loadRuleset, as a standard copies query; formula-error, cause unknown-name, otherwise). when is an optional activation condition, and scope says whether the item keeps it while held (holder, the default) or while it is the source of an active, unblocked applied entry (applied), as grants say.
  • Every copy (features and inventory, standard copies included) whose base item the query matches keeps the template, after its saved templates, unless the template’s appliesTo doesn’t match the item (then it isn’t kept; no error). Kept templates follow in instance order of the content keeping them; one the copy already has, saved or kept, isn’t added again.
  • A kept template does everything a saved one does: fields, tags, state keys, effects, grants, choices (<template>/<choice> in the copy’s tree), actions and follow-ups. self.templates and copy.templates list the saved templates, then the kept ones.
  • Kept templates are never saved: removeTemplate of one only kept is not-picked, and applyTemplate saves it. They are found in the same rounds as standard copies (engine-core.md), and computeSheet lists each with keptBy (engine-core.md).
  • Fields the kept templates leave alone. keepsFields lists item fields the templates an item keeps don’t change on the copies it keeps them on: a specific magic weapon’s Price already counts the runes it comes with, so its runes’ prior.price + 35 doesn’t apply, while a rune the player etches later (a saved template) still adds its Price. The fields are each keeping item’s; a template kept by several items leaves the union alone.
packs/orc/2e/content/weapons/specific/sky-hammer.yaml
fields: { price: 70000 }
keepsFields: [price]
keepsTemplates:
- template: runes.weapon-potency-3
copies: { type: weapon, where: 'item.id == @weapons.specific.sky-hammer' }

A query (query.schema.json) is a link to a set of things written as a rule rather than a list: { type, where }. type is a content type name, field or template. where is a condition evaluated with the candidate bound to item.

  • A query that refers only to item is resolved once by loadRuleset, after all packs are merged. This is how matching homebrew joins automatically.
  • A query that refers to the character (fields, self, character) is evaluated during computeSheet and listOptions.

Queries appear in choices (from), templates (appliesTo), grant values ({ query }), immunities (immuneTo), standard copies (standardCopies) and display groups (members).

The rules, and any content item, may declare standardCopies: queries whose matching items every character holds a copy of. The rules’ copies are on every character; an item’s exist while the item is active (the ritual FitD’s Ritual ability starts with). The queries read only item and are resolved by loadRuleset (unknown-name otherwise), so homebrew with the tag joins and a hidden item drops out.

packs/fitd/scoundrel/content/special-abilities/ritual.yaml
standardCopies:
- { type: ritual, where: 'item.id == @rituals.ritual' }

A standard copy is a copy like any other, in inventory for carried types and features otherwise: it has self.equipped, self.quantity, state, templates and choices, count, sumCopies and copy queries see it, and its actions are listed with its entry. Its instance ID is the item’s qualified ID with the colon and dots as hyphens (fitd-scoundrel:rituals.ritual → fitd-scoundrel-rituals-ritual); two standard items that derive one ID are the load error duplicate-id. The character file saves a standard copy only once it differs from its defaults (characters.md), and it can’t be removed: removeItem is standard-copy. It counts toward its type’s copy limit; rules-level standard copies that alone exceed a type’s max could never be valid, and pnpm validate reports them.

  • grants: [{ item, when }] makes other content active, such as a class granting its features “at level N” (when: "self.level >= 3"). Multiclassing gives each class copy its own level state. The total level is a derived field.

  • Waived prerequisites: a grant may say waivePrerequisites: true, and so may a choice: the granted or picked item’s activation through it reports no prerequisite-not-met, and listOptions gives that choice’s options no prerequisite reasons. Only the item’s own prerequisites are waived; what it grants or picks in turn is checked as usual. A deep orc gets Terrain Expertise whether or not it is trained in Survival: grants: [{ item: feats.skill.terrain-expertise, waivePrerequisites: true }].

  • prerequisites: [{ id, when }]: a false prerequisite is the rules issue prerequisite-not-met, carrying the qualified owner and prerequisite ID. It is checked against the current character, so a feat whose prerequisite held when it was taken but doesn’t now is flagged the same as one taken illegally. listOptions reports the same issue as the reason an option is ineligible. Text: <owner>.prerequisites.<id>.

  • A choice that requires an option: a choice may say requiresOption: true. While it is pending and none of its options is eligible, its owner has the rules issue no-eligible-option (id the owner plus the choice ID, data { path }), and listOptions, which checks an option as if it were picked, gives that issue as the reason the owner is ineligible. 5E’s feat instead of an Ability Score Improvement is offered only while some feat is open to the character, whatever feats the loaded packs add:

    id: improvements.feat
    type: improvement
    choices:
    - { id: feat, from: { type: feat }, requiresOption: true }

A choice (choice.schema.json) is declared on content, on an inline option, or in the rules:

Property Meaning
id Unique among its owner’s choices
slots Integers written out: one choice per slot, keyed <id>-<slot>, with slot bound (below)
from Where the options come from: a query, { items: [refs] }, { values } (plain values such as skill names; below), or { options: [inline options] }
pick Count (formula) or { min, max }. Default 1.
when When it applies (self.level >= 1). Before that it is neither pending nor active.
required Default true. A required choice with too few picks is pending.
recommend Formula naming the recommended option
effects Applied once per pick, with the option bound to pick. Used for value options (“grant proficiency in pick.value”).
activate Content choices: false keeps the picks inactive (below). Default true.
prompt Override for the default key <owner>.choices.<id>.prompt
eligible Per option, apart from from: a condition with item (content) or value, and choice (below)

Picked content becomes active: its effects, grants and its own choices apply. Content that declares state (its type’s or its own) keeps it on the pick: it reads self.<key> from the pick’s state (else the declared default) over the owner’s copy, and setState and adjustState change it by the pick’s path and option, or pick: self in its own action (engine-core.md). An inline option (id, tags, prerequisites, effects, grants, choices) behaves the same way. Text: <owner>.choices.<choice>.options.<id>.name.

computeSheet lists a character’s applicable choices, answered or not, as choices, and the ones still needing picks as pending (engine-core.md).

from: { values } offers plain values, strings or numbers:

  • values is a list written out, or { formula }: a list of strings or numbers evaluated against the character, links included, so the options can come from a linked sheet. Each value is offered once, in the list’s order. A formula that fails offers nothing.
  • where is a condition over each candidate, bound as value, with the character’s fields, character, links, and the owner’s item and self. It is evaluated with the sheet’s values, like a query’s where that reads the character.
  • A candidate that fails where is option-not-eligible in listOptions, and a saved pick that fails it, or that a formula no longer lists, is the same rules issue. A pick still counts and its effects still run, so a choice’s effects guard what must not apply.
  • Text for a value is <owner>.choices.<choice>.options.<value>.name when the pack has it; otherwise the app names it (a content ID by its item’s name, a field ID by the field’s).
packs/5e/srd-5.1/content/features/rogue/expertise.yaml
from:
values: [skill.acrobatics, skill.animal-handling, …, skill.survival, tool.thieves-tools]
where: >-
value in proficiencies.skills
|| (value == 'tool.thieves-tools' && '5e-srd-5.1:tools.thieves-tools' in proficiencies.tools)

A content choice (a query or items) may say activate: false. Its picks are saved, counted and checked against the query as usual, and its effects run per pick with pick.id, but the picked content isn’t active: none of its effects, grants, choices, actions or follow-ups apply, and listOptions checks none of its prerequisites. A pick is then a reference, such as a tool the character is proficient with but doesn’t carry. A hidden pick is still content-hidden.

packs/5e/srd-5.1/content/classes/bard.yaml
- id: instruments
pick: 3
from: { type: tool, where: "item.category == 'musical-instrument'" }
activate: false
when: self.startingClass
effects:
- { target: proficiencies.tools, op: grant, value: { formula: pick.id } }

Nested choices: an option’s own choices become pending as soon as it is picked, and are asked right after the choice that picked it, before its owner’s later choices: a mastermind rogue’s skill comes before the class’s free skills, which can then leave it out. The character stores picks as a tree:

choices:
martial-archetype:
- option: 5e-srd-5.1:subclasses.champion
choices:
additional-fighting-style: []

Un-picking an option removes its nested picks in the same all-or-nothing change. Choice paths for changes and listOptions alternate choice IDs and picked options (change.schema.json#/$defs/choicePath).

Nesting depth is limited by the maxChoiceDepth safety limit.

A choice may list slots: integers written out, such as the levels a feat is gained at. It is then one choice per slot, in list order, where it is declared:

  • Keys: each slot is the choice <id>-<slot> (class-feat-2). Its picks are kept under that key, choice paths and listOptions use it, and unique groups, issues and pending choices see each slot as its own choice. A slot’s key that equals another choice of the same owner is a load error (duplicate-id).
  • slot: in every formula of the choice (when, pick, recommend, the query’s where, a value choice’s values and where, effects) and of its inline options, slot is the slot’s number (an int). when is still an activation condition: slot is a constant there. In a choice without slots, slot is unknown-name. Content a slot picks doesn’t see it.
  • Text: one prompt for every slot, <owner>.choices.<id>.prompt, with the slot as the ICU argument {slot}; options as for any choice, under the declared ID. A pending slot and needs.choice carry slot: { choice, number }, the declared ID and the number.

The list is written, not computed: slots are saved choice keys, so they must not depend on the sheet.

packs/orc/2e/content/classes/fighter.yaml
- id: class-feat
slots: [1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
unique: feats
when: level >= slot
from:
type: feat
where: "item.category == 'class' && item.class == 'orc-2e:classes.fighter' && item.level <= slot"
# i18n/en.yaml
classes.fighter.choices.class-feat.prompt: Fighter feat (level {slot})

A choice may join an exclusivity group with unique: <key>:

  • An option picked in one choice of the group is ineligible in the others.
  • listOptions gives it the reason already-picked, and the sheet reports a repeated pick with the same issue.
  • A choice never excludes its own picks.
  • Content with repeatable: true is exempt: picked in one choice of the group, it stays eligible in the others and isn’t already-picked. orc-2e’s Skill Training can be taken in two feat slots.
  • The key is pack-qualified like other IDs: a local key (feats) is the declaring pack’s group, and a qualified one (orc-2e:feats) joins that pack’s, so an extension’s or homebrew’s choices share a dependency’s group. The pack must be the declaring pack or one of its dependencies (the validator reports another).
  • Per owner: uniqueScope: owner keeps the group to the choices of one owner activation: one copy, one pick or grant of the content, one inline option, or the rules. Two choices of a feat can’t take the same option, while the feat taken again (another activation) takes it freely. The default, character, is the whole character. uniqueScope needs unique.

5E’s fighter and Champion fighting-style choices share unique: fighting-style. orc-2e’s Skill Mastery raises one skill to master and another to expert, and may be taken again for the same skills:

choices:
- { id: to-master, unique: mastery, uniqueScope: owner, from: { values: [skill.acrobatics, …] } }
- { id: to-expert, unique: mastery, uniqueScope: owner, from: { values: [skill.acrobatics, …] } }

A choice’s where (a query that reads the character, or a value choice’s), its eligible, its recommend and a value choice’s values formula see choice: the choice itself, { path, owner, id, slot?, picks }, as a choice step over pending choices sees it, plus picks, its own picks (content IDs or values, in saved order). So a choice can offer what the character doesn’t have yet without its own picks ruling themselves out:

packs/5e/srd-5.1/content/classes/wizard.yaml
from:
values: [skill.arcana, skill.history, …]
where: '!(value in proficiencies.skills) || value in choice.picks'

A content choice (a query or items) or a value choice may say eligible: a condition each option must meet, apart from the list from gives, with item the content option’s item (or value), the character’s fields, the owner’s item and self, and choice. listOptions lists what from gives and marks an option failing eligible option-not-eligible; a saved pick failing it is the same rules issue. So a query’s content-only where stays a filter, resolved at load, and the character’s part is eligibility:

packs/5e/srd-5.1/content/backgrounds/acolyte.yaml
from: { type: language, where: "!('secret' in item.tags) && !('monster' in item.tags)" }
eligible: '!(item.id in proficiencies.languages) || item.id in choice.picks'

Cost. A content-only where is resolved once at load, and eligible is a condition on the computed sheet. A where that reads the character is checked by listOptions with the option picked, a trial sheet per candidate: cheap for a few dozen options, slow for hundreds (orc-2e’s class feats). A pack with long lists keeps where on the item (its level, its traits) and puts the character’s part in eligible.

A choice of inline options has no eligible (a schema error): its options have prerequisites. A pick another source also gives (an acolyte cleric’s class skill Insight) passes such a condition, being the choice’s own pick; the rules issue is the app’s to raise, if the game wants it.

Picks for a copy’s choices live in its entry’s choices tree:

  • Bare keys: the entry’s own item uses bare choice IDs.
  • Prefixed keys: choices declared by its templates or by content it grants are keyed <qualified owner id>/<choice id>, so they can’t collide.
  • Nested picks: a picked option’s own choices nest under the pick, where their owner is unambiguous.

Choice paths in changes and listOptions use the same keys.