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
}

kindsingle (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

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.