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.

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": []
}

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" }
] } }

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"
}

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" } ]

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": { ... } }

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" } ]

A10. Dependencies and supplements

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:

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.

When it cannot be merged. Every case is refused by name, never rendered half-merged — the same rule as an engine range (A1):

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.