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).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 and every dependency at creation, and changes it only through amigrate_packop.- A supplement sets
extends: {id, sheets?}and may contain only additive sections:addFields(with anafteranchor),addSections,addItemTemplates,addEffects,addLookups,addRolls,strings, andoverrideFields(same id, replaces the definition; lint warning). The merge is base pack, then supplements in the order the character lists them. An id collision withoutoverrideFieldsis a merge error reported when the supplement is enabled. A supplement cannot remove anything.
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.