This page is Part A of the specification the three engines implement, rendered from spec/SPEC.md at build time. Where a renderer and this text disagree, the text wins and the renderer has a bug.
Part A — The pack format
A1. Scope and identity
A pack describes how a game's sheet is drawn and computed: sheets, fields, layout, items, effects, dice, rosters, lookups, strings and assets. A pack contains no character data. A pack is data, never code.
schemaVersion— integer,1for this document. A client refuses a pack whoseschemaVersionis greater than the one it knows.engine— a semver range (">=1.0.0 <2.0.0") the pack was linted against. A client whose engine version is outside the range refuses the pack by name ("This pack needs SheetDemon 1.2 or newer") rather than rendering it partially.pack.id—<handle>/<slug>, both[a-z0-9-]{1,64}. The handle is the publishing account's handle in the registry; a fork gets a new id under the forker's handle.pack.version— strict semverMAJOR.MINOR.PATCH. The registry key is(id, version); a published version is immutable.- Ids inside a pack (
sheets[].id,fields[].id,columns[].id,itemTypes[].id,itemTemplates[].id,effects[].id,lookups[].id,rolls[].id,containers[].id,slots[].id, layout node ids, effect param ids) match^[a-z][a-z0-9_]{0,63}$and are unique within their namespace. Ids are the expression namespace, so they are identifiers, not labels. - Every human-visible string is a key into
strings.<locale>(labelKey,descriptionKey,textKey,changelogKey). Inline prose is a lint error.
A2. Archive
A pack travels as pack.json alone or as a .sheetpack zip containing
pack.json at the root and an optional assets/ directory. Assets are referenced
as asset:<assetId> and declared in assets with their path, MIME type, byte size
and SHA-256; the hash is verified on import. Limits: pack.json ≤ 1 MiB; ≤ 64
assets; ≤ 256 KiB each; ≤ 4 MiB total; MIME ∈ image/svg+xml, image/png,
image/webp.
A3. Top-level shape
{
"schemaVersion": 1,
"engine": ">=1.0.0 <2.0.0",
"pack": {
"id": "sophia/frostgrave-style-warband",
"version": "1.3.0",
"nameKey": "pack.name",
"author": { "handle": "sophia", "displayName": "Sophia" },
"gameSystem": "Frostgrave-style skirmish",
"license": "CC-BY-4.0",
"tags": ["skirmish", "warband", "fantasy"],
"descriptionKey": "pack.description",
"attributionKey": "pack.attribution",
"disclaimerKey": "pack.disclaimer",
"icon": "glyph:warband",
"homepage": "https://example.org",
"forkedFrom": { "id": "someone/warband", "version": "1.0.2" },
"changelogKey": "pack.changelog",
"price": null
},
"defaultLocale": "en",
"dependencies": [ { "id": "sophia/warband-core", "version": "^1.2.0", "optional": false } ],
"extends": null,
"sheets": [],
"itemTypes": [],
"itemTemplates": [],
"effects": [],
"lookups": [],
"rolls": [],
"strings": { "en": {}, "el": {} },
"assets": {},
"migrations": []
}
licenseis a closed list:CC0-1.0,CC-BY-4.0,CC-BY-SA-4.0,MIT,OGL-1.0a,all-rights-reserved. Forking is allowed for all but the last.iconisglyph:<name>from the fixed glyph set the design system ships, orasset:<id>.priceis reserved (nullin v1).- A supplement (A10) sets
extendsand addsaddFields,addSections,addLookups,addRolls,addEffects,addItemTemplatesandoverrideFields; a base pack carries none of those, and a supplement carries nosheets. defaultLocalemust be complete instrings; other locales fall back per key to the default locale, with a lint warning and a completeness percentage the registry displays.
A4. Sheets and fields
A pack has one or more sheets. A character instance pins one sheetId.
{
"id": "character",
"labelKey": "sheet.character",
"kind": "single",
"fields": [ ... ],
"layout": { ... },
"inventory": { ... },
"members": null
}
kind ∈ single (a character), roster (a container of members, see A8),
member (a sheet only ever used as a roster member).
Field definition, common keys: id, type, labelKey, descriptionKey,
tags[], category (∈ stats, resources, items, xp, effects, notes,
other; default other; drives GM permission checks), readOnly, hidden,
summary (boolean; at most 4 per sheet), header (boolean; resources only; at
most 4 per sheet), gmOnly.
type |
Type-specific keys | Stored value |
|---|---|---|
integer |
default (required), min, max, step (default 1), deltaEdit (bool) |
int |
decimal |
default, min, max, scale (default 2) |
number |
text |
default (""), maxLength (≤ 1000), pattern (RE2-safe subset: literals, classes, * + ? {n,m}, groups, anchors; no backreferences or lookaround), long (bool; opens the edit sheet on phone) |
string |
longtext |
maxLength (≤ 50 000), markdown (bool) |
string |
boolean |
default |
bool |
enum |
options[{value, labelKey, descriptionKey?, data?}], nullable, default |
string or null |
multiEnum |
options[...], max |
string[] |
resource |
maxFormula (sx, required), min (default 0), maxEditable, overflow (clamp | allow), default ("max" or int), shape (bar | pips | counter), kind (a resource token: vital, arcane, stamina, ammo, wealth, neutral), temporary (bool: a temporary pool exists), quickAdjust (int[]) |
{current, maxOverride, temp} |
dice |
default (a dice expression) |
string |
ref |
target: {kind: item | roster_member | table_row, role?, itemType?, table?}, nullable |
id string or null |
table |
columns[] (field definitions of any type except table, inventory, resource), minRows, maxRows (≤ 1000), rowLabelColumn, rowRoll (a roll id with row: scope), category |
{rowId: {sort_key, ...cols}} |
computed |
valueType (integer | decimal | boolean | text), formula (sx, pure), scale |
not stored |
stat |
default, min, max, modifierFormula (sx, pure; evaluated with value bound to the score) |
int |
heading | divider | static |
textKey (static only), style |
not stored |
enum.options[].data is a free JSON object of scalars exposed to sx as
optiondata("<fieldId>", "<key>") for the currently selected option.
A5. Layout
One layout tree per sheet, drawn by both renderers from the same nodes. It is a form/grid model with three size classes; there are no pixels.
"layout": { "root": { "type": "column", "children": [
{ "type": "section", "id": "abilities", "labelKey": "sec.abilities", "collapsible": true,
"span": { "medium": 6, "expanded": 4 }, "children": [
{ "type": "grid", "children": [
{ "type": "field", "field": "str", "span": { "compact": 4, "medium": 2, "expanded": 2 }, "widget": "stat" }
] } ] },
{ "type": "tabs", "children": [
{ "type": "tab", "labelKey": "tab.combat", "children": [
{ "type": "resource", "field": "hp" },
{ "type": "roll", "roll": "initiative" },
{ "type": "effects" } ] },
{ "type": "tab", "labelKey": "tab.spells", "visibleWhen": "level >= 2", "children": [
{ "type": "table", "field": "spells", "columns": ["name", "lvl", "prepared", "dc"], "addRow": true } ] } ] },
{ "type": "inventory", "containers": ["backpack"], "showSlots": true, "showCurrency": ["gp", "sp"] },
{ "type": "text", "textKey": "help.rest", "style": "caption" },
{ "type": "divider" }
] } }
- Node types:
column,row,grid(12 columns),section,tabs/tab,field,resource,table,inventory,roll,effects,members(roster sheets only),text,divider,spacer. - Every node accepts
id,span(per size class; default 12),visibleWhenandreadOnlyWhen(sx booleans in sheet scope), andwidget(a hint from a closed per-type list:stepper,slider,toggle,chips,radio,dropdown,bar,pips,counter,compact,stat; a renderer falls back to its default when it does not support the hint). - Size classes:
compact(< 600 dp),medium(600–839),expanded(≥ 840). Oncompactthe renderer ignores spans and stacks sections in order behind a section tab strip. Both renderers compute the class from width alone. - Phone order is document order. Authors control order, section tabs and the
header/summaryflags; everything else about compact layout belongs to the renderer. resolveLayout(pack, sheetId, snapshot, sizeClass) → [{nodeId, visible, readOnly, span}]is engine code and is fixture-tested (spec/fixtures/layout/).- A field with no layout node is a lint warning (it is still stored and evaluated).
- Node ids: an explicit
id, otherwise generated asfield:<fieldId>,resource:<fieldId>,table:<fieldId>,roll:<rollId>,inventory,effects,members, and<parentId>/<index>for structural nodes (column,row,grid,tabs,tab,text,divider,spacer) without one. AvisibleWhenon an ancestor hides every descendant; areadOnlyWhenon an ancestor makes every descendant read-only.
A6. Items and inventory
"itemTypes": [
{ "id": "weapon", "labelKey": "it.weapon", "icon": "glyph:sword", "stackable": false,
"properties": [
{ "id": "damage", "type": "dice", "labelKey": "p.damage", "default": "1d6" },
{ "id": "weight", "type": "decimal", "labelKey": "p.weight", "default": 0, "scale": 1 },
{ "id": "finesse", "type": "boolean", "labelKey": "p.finesse", "default": false },
{ "id": "to_hit", "type": "computed", "valueType": "integer", "labelKey": "p.to_hit",
"formula": "prof + if(item.finesse, max(str.mod, dex.mod), str.mod)" } ],
"rolls": ["attack", "damage"] } ],
"itemTemplates": [
{ "id": "longsword", "type": "weapon", "labelKey": "tpl.longsword", "tags": ["martial"],
"properties": { "damage": "1d8", "weight": 3 },
"modifiersWhenEquipped": [] },
{ "id": "ring_protection", "type": "wondrous", "labelKey": "tpl.ring_prot",
"modifiersWhenEquipped": [ { "target": "ac", "op": "add", "value": "1" } ] } ]
Per sheet:
"inventory": {
"enabled": true,
"containers": [ { "id": "backpack", "labelKey": "ct.backpack", "capacityFormula": "str * 15" },
{ "id": "stash", "labelKey": "ct.stash" } ],
"slots": [ { "id": "main_hand", "labelKey": "sl.main", "accepts": ["weapon", "shield"], "max": 1 },
{ "id": "ring", "labelKey": "sl.ring", "accepts": ["wondrous"], "max": 2 } ],
"currencies": ["gp", "sp"],
"weightField": "carry"
}
- Currencies are ordinary
integerfields withdeltaEdit: true;currenciesonly tells the inventory node which to show. - An item instance is state (Part C):
{item_id, type_id, template_id?, name?, qty, equipped, slot_id?, container_id?, props{}, notes};propsoverride the template'sproperties. - Equipped templates contribute
modifiersWhenEquippedto evaluation (C5). - Slot capacity and
acceptsare validated when the op is created, not in the fold.
A7. Effects and conditions
"effects": [
{ "id": "poisoned", "labelKey": "ef.poisoned", "kind": "condition", "icon": "glyph:poison",
"params": [],
"modifiers": [ { "target": "tag:attack", "op": "add", "value": "-2" } ],
"defaultDuration": { "kind": "until_removed" }, "stack": "none", "condition": true },
{ "id": "str_drain", "labelKey": "ef.str_drain", "kind": "debuff",
"params": [ { "id": "amount", "type": "integer", "default": 2, "min": 1 } ],
"modifiers": [ { "target": "str", "op": "add", "value": "-param.amount" } ],
"defaultDuration": { "kind": "rounds", "count": 3 }, "stack": "sum" },
{ "id": "bless", "labelKey": "ef.bless", "kind": "buff",
"modifiers": [ { "target": "tag:attack", "op": "add", "value": "1d4", "rollPerUse": true } ],
"defaultDuration": { "kind": "rounds", "count": 10 }, "stack": "none" } ]
kind∈buff,debuff,condition,neutral(a design token).condition: truelists the effect in the conditions toggle list.target: a field id,<resourceId>.max,tag:<tag>(every field, resource max and roll carrying the tag), orroll:<rollId>.op∈add,mul,set,min(floor),max(cap). Application order per target: alladd(summed) → allmul(multiplied) → the lastsetbypriority(int, default 0) then by op sequence →min/max→ the field's ownmin/max→ rounding toscale.valueis sx in effect scope (param.*,effect.stacks, plus sheet scope). A dice-typed value is legal only withrollPerUse: true; it is not folded into the number but shown as an extra term on the roll and rolled at roll time.- Durations:
{kind: "permanent"}(irreversible: the client folds it intoset_fieldops withcause),{kind: "until_removed"},{kind: "rounds", count},{kind: "turns", count},{kind: "sessions", count}. Timed kinds are read against the game clock (C8); outside a game the clock is{round: 0, turn: 0, session: 0}and timed effects never expire on their own. Two more kinds exist only on an instance at a table, never in a pack:{kind: "until_turn_start", character}and{kind: "until_turn_end", character}, set when the effect is applied and read against the encounter view of the clock (C5). stack∈none(only the last live instance applies, so re-applying behaves as a refresh),sum(every instance applies),highest(only the live instance with the largest first param applies; on a tie the earliest).- An ad-hoc effect (typed by a GM or player) is the same shape with
effect_id: nulland inlinemodifiersin the op payload.
A8. Rosters
A roster is a sheet with kind: "roster":
{ "id": "warband", "kind": "roster", "labelKey": "sheet.warband",
"fields": [
{ "id": "treasury", "type": "integer", "default": 500, "min": 0, "deltaEdit": true, "category": "items", "labelKey": "f.treasury" },
{ "id": "upkeep", "type": "computed", "valueType": "integer", "formula": "sum(members(), it.upkeep)", "labelKey": "f.upkeep" } ],
"members": [
{ "role": "wizard", "sheet": "wizard", "min": 1, "max": 1 },
{ "role": "apprentice", "sheet": "apprentice", "min": 0, "max": 1, "deriveFrom": { "role": "wizard", "fields": { "fight": "fight - 2", "will": "will - 2" } } },
{ "role": "soldier", "sheet": "soldier", "min": 0, "max": 8 } ],
"sharedInventory": true,
"rules": [ { "id": "one_wizard", "when": "count(members(\"wizard\")) != 1", "messageKey": "rule.one_wizard", "severity": "error" } ],
"layout": { ... } }
- Members are full characters (
kind: member,roster_id,member_role). - Member sheets reach shared state through
roster.<field>; the roster reaches members throughmembers()/members("<role>"). sharedInventory: truerejects item ops on members; the inventory lives on the roster and members reference roster items throughreffields.deriveFromseeds a new member's fields from another member by role, with sx in the source member's scope; the values are then ordinary editable state.rulesare evaluated on the roster snapshot and rendered as a banner;errorrules block nothing (the app never refuses to save a sheet) but are shown.
A9. Lookups, rolls, strings, assets, migrations
"lookups": [
{ "id": "prof_by_level", "keyType": "integer", "valueType": "integer", "mode": "band", "default": 2,
"entries": [[1,2],[5,3],[9,4],[13,5],[17,6]] },
{ "id": "slots1", "keyType": "integer", "valueType": "integer", "mode": "exact", "default": 0,
"entries": [[1,2],[2,3],[3,4]] } ],
"rolls": [
{ "id": "initiative", "labelKey": "r.init", "scope": "sheet", "expr": "1d20 + dex_mod", "tags": ["attack"], "placement": "header" },
{ "id": "attack", "labelKey": "r.attack", "scope": "item:weapon", "expr": "1d20 + item.to_hit", "tags": ["attack"] },
{ "id": "cast", "labelKey": "r.cast", "scope": "row:spells", "expr": "1d20 + prof + int_mod",
"onResult": [ { "action": "adjust_resource", "field": "spell_slots_1", "delta": "-1", "when": "row.lvl == 1", "confirmKey": "confirm.slot" } ] },
{ "id": "hit_dice", "labelKey": "r.hd", "scope": "sheet", "expr": "1d8 + con_mod",
"onResult": [ { "action": "adjust_resource", "field": "hp", "delta": "result.total" } ] } ],
"strings": { "en": { "f.str": "Strength" }, "el": { "f.str": "Δύναμη" } },
"assets": { "sword": { "path": "assets/sword.svg", "mime": "image/svg+xml", "bytes": 1820, "sha256": "…" } },
"migrations": [
{ "from": "<1.3.0", "renameFields": { "hitpoints": "hp" }, "renameColumns": { "spells": { "level": "lvl" } },
"dropFields": ["old_flag"], "noteKey": "mig.1_3_0" } ]
mode: "band"returns the value of the greatest key ≤ the input;exactrequires equality. A miss returnsdefault, ornullwhen there is none.- A roll's
tagslet effects target the roll.scope∈sheet,item:<type>,row:<table>.placement∈header,section(default).onResultactions (adjust_resource,adjust_field,set_field,apply_effect) are emitted as ops withcause: {kind: "roll", id}after the user confirms;whenis sx in roll-result scope. migrations[].fromis a semver range matched against the character's pinned version when it moves to this version (C7).
A10. Dependencies and supplements
dependenciesresolve like a lockfile. A character recordspack_lock: [{id, version}]for the root pack, every dependency and every supplement it has enabled, at creation, and changes it only through amigrate_packop.
A supplement is a pack whose extends is not null. It adds to another
pack's sheets — a spell list on the character sheet you already have — and has
no sheets of its own, so it is never rendered alone.
{
"extends": { "id": "sheetdemon/srd-5e-core", "version": "^0.1.0", "sheets": ["character"] },
"sheets": [],
"addFields": [ { "sheet": "character", "after": "spell_attack", "fields": [ …field definitions… ] } ],
"addSections":[ { "sheet": "character", "after": "magic", "node": { "type": "section", "id": "spellbook", … } } ],
"addLookups": [ … ], "addRolls": [ … ], "addEffects": [ … ], "addItemTemplates": [ … ],
"overrideFields": [ { "sheet": "character", "field": { "id": "spell_dc", "type": "computed", … } } ]
}
extends.id names the base pack; extends.version is a semver range
(^0.1.0, >=1.2.0 <2.0.0; absent means any version); extends.sheets, when
given, is the closed list of base sheets the supplement may touch.
What a supplement may contribute: addFields, addSections, addLookups,
addRolls, addEffects, addItemTemplates, overrideFields, strings and
assets. Nothing else.
What it may not do — a supplement cannot break the base pack:
- It carries no
sheets,itemTypes,itemTemplates,effects,lookups,rollsormigrationsof its own (SCHEMAat the key). Those belong to a base pack;addEffectsand friends are how a supplement adds to them. - It removes nothing. There is no drop or remove contribution and no key that could hold one.
- It takes no id that already exists — field, lookup, roll, effect, item
template or layout node (
DUPLICATE_ID). - An
overrideFieldsentry must name a field that already exists on that sheet (UNKNOWN_REF) and must keep itstype(TYPE): an override may change a label, a default, a bound or a formula, never what the field is, because the character's stored value is already that type. It keeps the field's position in the sheet's field list, and it is a lint warning (B11.7). - It touches no sheet outside
extends.sheets, when that list is given (UNKNOWN_REF). - Supplements do not chain:
extends.idmust name a base pack, and extending a supplement isSCHEMAat/extends/id. - The merged pack must lint clean (B11), limits (A11) included. The base pack's own lint still holds with the supplement applied; a supplement that would break it is refused, not half-applied.
The merge. merge(base, [supplements]) → pack | errors is engine code in
all three engines, fixture-tested under spec/fixtures/supplements/. The
merged pack is the base pack: its pack metadata, engine,
defaultLocale, dependencies and migrations are the base's, and its
extends is null. Supplements apply in the order the character lists them,
each to the result of the last.
addFields[].fieldsare inserted into the sheet's field list immediately after the field named byafter, in the order given; withoutafterthey are appended. Field order is document order and document order is phone order (A5), so this is what the reader sees.addSections[].nodeis inserted into the sheet'slayout.root.childrenimmediately after the child whose node id (A5) isafter; withoutafterit is appended.afternaming no child of the root isUNKNOWN_REF. No node in the added subtree may take a node id the sheet already uses — an explicitid, or one generated from a field, roll or singleton node (DUPLICATE_IDat the offending node).addLookups,addRolls,addEffectsandaddItemTemplatesare appended to the base's arrays, in order.stringsmerge per locale, key by key. The base's value wins a collision and the merge warns (DUPLICATE_IDat/strings/<locale>/<key>) — a supplement cannot rename the base's labels. A supplement's own metadata keys (nameKey,descriptionKey,attributionKey,disclaimerKey,changelogKey) are not merged at all: the merged pack's identity is the base's.assetsmerge the same way, but an id collision is an error.- The supplement's
defaultLocalemust be the base's (SCHEMAat/defaultLocale).
When it cannot be merged. Every case is refused by name, never rendered half-merged — the same rule as an engine range (A1):
- The base pack is not there — not installed, or the pack offered is a
different one:
UNKNOWN_REFat/extends/id. - The base is a version the supplement does not allow —
pack.versionoutsideextends.version:UNKNOWN_REFat/extends/version. - Two supplements touch the same sheet: allowed, and the order is the
character's. The second merges into the result of the first, so its
afteranchors may name what the first added, and an id the first already took isDUPLICATE_IDat the second supplement's own path. - A supplement that fails is applied not at all: the merge stops at the first supplement with an error and returns that supplement's errors, so a merged pack is either whole or absent.
Switching one on or off. A character's enabled supplements are the entries
of pack_lock after the first, in order, and the lock changes only through a
migrate_pack op (C3, C7) — the same op a pack upgrade uses, with renames,
column_renames and drops all empty. So switching a supplement off is not
destructive: its fields stop being contributed and stop being drawn, and the
values they held stay in the base doc untouched (an unknown path survives a
fold, C3), so switching it back on shows the same numbers again. A client that
offers the switch says so. A client refuses a lock it cannot merge before it
writes the op, by name, and the server refuses one it cannot serve or merge
(INVALID_OP, C11): a lock that does not resolve would leave the sheet
unreadable.
A11. Limits
| Thing | Limit |
|---|---|
pack.json |
1 MiB |
| Sheets per pack | 20 |
| Fields per sheet (incl. computed) | 2 000 |
| Table columns / rows | 32 / 1 000 |
| Item types / templates / effects / lookups / rolls | 100 / 5 000 / 500 / 200 / 500 |
| Item instances per character | 500 |
| Active effect instances per character | 100 |
| Expression source / AST depth | 2 000 chars / 64 |
| Dice per roll / sides | 100 / 1 000 |
| Assets | 64, ≤ 256 KiB each, ≤ 4 MiB total |
| Locales | 20 |
longtext |
50 000 chars |
summary / header fields per sheet |
4 / 4 |
A pack over any limit fails lint with LIMIT and the path.