Ruleset Catalogs

Use this page to ship ready-made entries (spells, gear, class features) that the sheet editor offers in a picker, so players do not type long lists by hand. It covers the catalog header, entries, values the ruleset keeps, sheet commands and the use command. It is for ruleset authors. Before you start, read Writing Game Mode Rulesets.

Typing a long spell list or gear table row by row is slow. A catalog is a named collection of ready-made entries that you ship with the ruleset. The sheet editor offers them in a picker on every list the catalog feeds, and picking one fills the row in.

A catalog is optional. A ruleset may have up to twelve of them, and nothing in the Engine knows what any of them are about: every id, column, filter, and word comes from your file.

The header

The header goes in catalogs at the top level of the file, beside gm.

"catalogs": [
  {
    "id": "knacks",
    "label": "Knacks",
    "feeds": ["knacks", "tricks"],
    "filters": [
      { "id": "grit", "label": "Grit cost", "type": "number" },
      { "id": "road", "label": "Road", "type": "text" },
      { "id": "callings", "label": "Calling", "type": "tags", "startFrom": { "field": "calling" } }
    ],
    "units": { "distance": { "label": "paces", "perCell": 2 } },
    "entries": []
  }
]

An entry

{
  "id": "road-sense",
  "label": "Road Sense",
  "summary": "You read a road the way other people read a face.",
  "filters": { "grit": 0, "road": "Ash Flats", "callings": ["Scout", "Courier"] },
  "rows": [
    {
      "list": "knacks",
      "values": { "name": "Road Sense", "notes": "Sneak to notice where a road turns bad." }
    }
  ]
}

Every value is checked against the target list's columns, so a mistyped column name or a number outside a column's range is reported with the entry it came from. Entries written inside the ruleset file are checked when the ruleset is loaded, which for an imported file means at import. A package's separate catalog file is checked when the picker first asks for it, and a file with a mistake shows its reasons there instead of any entries.

One entry, several lists

A feature with limited uses is two rows on a sheet: the feature itself, and the counter that tracks it. That is still one pick.

{
  "id": "last-ember",
  "label": "Last Ember",
  "rows": [
    {
      "list": "knacks",
      "values": { "name": "Last Ember", "notes": "Spend 1 Grit to give a downed friend 3 Grit back." }
    },
    { "list": "tricks", "values": { "name": "Last Ember", "uses": 1, "recharge": "camp" } }
  ]
}

Values the ruleset keeps up to date

A row's numbers belong to the player once it is picked. One exception is worth having: a maximum that follows the character, such as uses equal to an ability score, or a class resource that grows with a level. A row may name up to four of its own number columns in a scaled map, and the sheet editor keeps those cells right.

{
  "list": "tricks",
  "values": { "name": "Last Ember", "uses": 1, "recharge": "camp" },
  "scaled": { "uses": { "from": { "abilityScore": "heart" } } }
}

more complicated is a derived value your sheet declares, which from then points at ("from": { "derived": "lay_on_hands_max" }). No new arithmetic is added here.

level gives a number: "scaled": { "max": { "from": { "field": "level" }, "table": [[1, 2], [3, 3], [6, 4]] } }.

That is what the row is before any sheet is known, and what a sheet with no such reference keeps.

matches one spec, or (Capability API 1.92) every row the entry writes to that list must carry its own name in the list's name column (its pools' nameColumn, else its first text column). The sheet's row is then matched to its spec by that name, so one trait can give two uses that each grow on their own; a row the player renames is left alone.

The value is worked out when the sheet is edited and never when it is read, so a stored row is always the number it says it is. It is fitted to the column it lands in: clamped to the column's min and max, and rounded down when the column takes whole numbers. In the example above, a character with Heart 3 has three uses and one with Heart 0 or less has none. The row stays on their sheet with 0 uses, and because a counter with a maximum of 0 is not a pool, there is nothing to spend in play.

Scaled columns are Capability API 1.23 for a packaged ruleset. A community ruleset you import is validated by the Engine that reads it, so it needs nothing.

Picked rows are copies

Each picked row is copied onto the sheet with one extra key, _catalog, holding <catalog id>/<entry id>. Column ids always start with a letter, so this key can never be one of yours.

The copy is the character's. The player can edit any of it afterwards, the sheet keeps working while your ruleset is not installed, and publishing a new version of the ruleset never rewrites anyone's character. The mark is what the picker reads to show what a sheet already has, and what Refresh reads below.

Refresh from ruleset

Because a picked row keeps its mark, the sheet editor can tell a player when your newer text differs from what their row holds. A short line under the list says how many rows have newer text, and a Review button shows each of them with what the sheet holds beside what the ruleset says, and a tick per row. Nothing is written until the player clicks Update selected, and only the columns that differ in the ticked rows are written. Everything else in the row survives, the mark included.

What is compared is deliberately narrow:

So rewording or renaming an entry can reach characters who already picked it, if they accept it. Changing what a number means cannot, and will not: that column is the player's once the row is theirs.

Sheet commands: permanent advancement

A ruleset may opt into changes to the permanent build with sheet.advancement:

"advancement": {
  "fields": ["xp", "level", "humanity", "discipline"],
  "abilities": true,
  "skills": true,
  "saves": true,
  "text": "Award XP at the end of a session; change other values only when earned."
}

Only include field ids your sheet declares as number or text (text or longtext), at most 24. The booleans allow all abilities, skills or saves respectively; omitted or false means no permission. An empty permission set, unknown field, unsupported field type or unknown key is refused on import. text is optional guidance of at most 400 prompt-safe characters. Without advancement, the existing commands behave as before and every advance request is refused.

[sheet: op="advance" field="xp" by="250" who="party"]
[sheet: op="advance" field="xp" to="1200"]
[sheet: op="advance" field="humanity" by="-1"]
[sheet: op="advance" field="discipline" to="Auspex"]
[sheet: op="advance" ability="str" by="1"]
[sheet: op="advance" ability="str" to="16"]
[sheet: op="advance" skill="stealth" tier="expert"]
[sheet: op="advance" save="will" tier="trained"]

Name exactly one target. Fields and abilities take exactly one of by or to; text takes only to. Names and tiers match ids or labels, ignoring case. Skills and saves accept any tier declared in resolution.proficiencyTiers. Number fields honor integer, min and max; abilities honor their integer bounds; text honors maxLength. An invalid value is refused with a bad-amount reason that states the bound, never silently clamped. Disabled advancement, disallowed targets, unknown names and unknown tiers are also refused. The rewritten tag carries result and the new value in now. Omit who for the player, or use who="party" for every unambiguous party card; partial refusals name which cards could not advance.

The Engine saves the build on the game's card through the same metadata storage as a player edit, keeping its sheet version and recomputing derived values from the new build. Library cards are not changed. Like live commands, an alternate swipe or regenerated reply starts before the outgoing reply's changes. Each reply records its ending build, including replies with no advancement. Selecting a swipe restores its snapshot; deleting the latest reply reveals the preceding snapshot. Deleting an older reply leaves a later snapshot in force until that later reply is removed. A continuation updates the same reply's snapshot. Undo changes only values still matching the recorded result, so later player edits survive and unrelated edits do not prevent an XP award from being undone.

The use command: letting the Game Master spend a price you wrote

While it narrates, the Game Master keeps each sheet up to date with [sheet: ...] commands: spend, restore (heal means the same thing), damage, temp, track, condition, note and rest. A ruleset that ships catalogs gets one more:

[sheet: who="Mira" op="use" name="Fireball"]
[sheet: who="Mira" op="use" name="Fireball" pool="3rd-level slots"]

op="cast" means the same as op="use" and spell= the same as name=, so the wording a Game Master reaches for works without your format having to know the word "spell".

The name is matched, ignoring case, against the rows on that character's sheet that came from one of your catalogs. A row answers to the name the Game Master was shown (the sheetSummary name column for that list, then the list's pools.nameColumn, then its first text column) and to the label of the entry it came from, so a player who renamed their row still has it. A name nothing answers to, and a name two different entries answer to, are both refused.

What it spends:

naming a pool GROUP pays from the first pool of that group, in declaration order, that can afford it. There is no automatic climb to a higher pool, because a group is not always a ladder.

uses. That is the second row of the Last Ember entry above. A counter whose maximum is 0 has no uses to give, so the command is refused instead of going through for free.

pool= is the upcast: the same single price, paid from another pool of the same group. It is only accepted when the cost has exactly one term and the named pool shares that term's group, or pays for it. Anything else is refused rather than reinterpreted.

A pool that pays for another group (Capability API 1.171). A live pool's pays names up to four other groups whose costs it may pay too, at its own level (its level, or its place in its own group), and never for a cost of a higher level than its own. 5e's pact slots pay for spells costed from the spell slots, as a multiclass warlock may:

{
  "id": "pact_slots",
  "label": "Pact Slots",
  "group": "pact",
  "max": { "field": "pact_slot_count" },
  "level": { "field": "pact_slot_level" },
  "pays": ["slots"]
}

A fight offers such a pool beside the higher pools of the spell's own group, and a payment from it grows the spell by as many steps as its level is above the spell's: a 3rd-level pact slot pays for Magic Missile as a 3rd-level slot would. A use whose own pool is empty is still offered, paid only from a higher pool or one that pays for it, so a 1st-level spell is cast with a 2nd-level slot once the 1st-level slots are gone. targetsPerCostStep (1.171, 1 to 10) on an entry's mechanics adds that many targets for each step it is paid up, to 20 at most: Hold Person is "targetCount": 1, "targetsPerCostStep": 1.

It is all or nothing. If any part cannot be paid the whole command is refused, nothing changes, and the player is told. An entry with no cost at all, such as a cantrip or a passive feature, is accepted and changes nothing.

Inline, or a file of its own

A small catalog sits inline in ruleset.json, in the header's entries. A long one lives in its own file and the header names it with asset instead. A catalog has exactly one of the two.

{ "id": "knacks", "label": "Knacks", "feeds": ["knacks"], "asset": "catalogs/knacks.json" }

The path is always catalogs/<the catalog's id>.json. The file itself looks like this:

{ "schemaVersion": 1, "catalog": "knacks", "entries": [] }

Separate catalog files come in a package: one published through the official catalog, or a ruleset package file you import yourself (see Sharing your ruleset). The package lists the file in contributions.assets.paths beside ruleset.json, and it needs Capability API 1.21. A catalog of creatures, inline or in its own file, needs Capability API 1.27, and a catalog of items needs 1.49. A ruleset you import as a single file, or share through a GitHub repository, carries its catalogs inline, which means they have to fit inside the 512 KB limit on the whole ruleset file. That is room for a few hundred short entries.

The limits are 12 catalogs per ruleset, 2000 entries per catalog either way, and 2 MB for one catalog file. A package whose ruleset.json is over 256 KB, or with a catalog file over 1 MB, needs Capability API 1.340, which raised both limits.