Skip to content
This repository was archived by the owner on Oct 10, 2026. It is now read-only.

Writing a Game System

jonathansatterthwaite-stack edited this page Oct 10, 2026 · 6 revisions

Pack Rat is an inventory engine. Everything that belongs to one game comes from a game system: its item templates, features, states, money, weight unit, catalog, roll tables, its own words for a few things, and the panels above the inventory.

D&D 5e (SRD 5.1) comes with Pack Rat, written as a system (js/systems/dnd5e.js, dnd5e-panels.js). So does a small made-up example deliberately unlike it, Slot Delve (js/systems/slot-delve.js): slots instead of pounds, the silver standard, armor points instead of Armor Class. Read the two side by side.

A system is a JSON file. Import it in Settings → Files → Import a file. Choose it for a campaign in the campaign's Edit. Export any system (Files → Game systems → Export) to get a file to start from.

The file

{
  "packrat": "system",
  "id": "my-game",
  "name": "My Game",
  "version": 1,
  "description": "…",
  "templates": [],
  "features": [],
  "commonFields": [],
  "states": [],
  "values": [],
  "stats": {},
  "campaignSettings": [],
  "terminology": {},
  "currency": {},
  "weight": {},
  "starterTemplates": [],
  "panels": [],
  "panelLib": "…",
  "catalog": [],
  "tables": [],
  "glossary": {}
}
  • packrat and id are required.
    • id must not be one Pack Rat already has: to change D&D 5e, export it and give your copy a new id.
    • Re-importing a file with the same id updates that system.
  • Everything else is optional, apart from at least one template.
  • A file Pack Rat can't use is refused when imported.

Templates

Templates decide an item's fields and what it can do. They nest: a template with a parent is a kind of that one.

  • The top templates (no parent) are what the catalog's list, the inventory filters and the colours show. Each has a hue.
  • The templates under one are its sub-filters, each in a shade of its hue (or its own hue). Its own categories are sub-filters too.
  • A template's categories are the next level down: under Gear & Tools, Tools, then Artisan's Tools, Gaming Set… (each a little lighter or darker in Tools' shade).
  • abstract: true: a template that only gathers others (Slot Delve's Arms & Armor; D&D 5e's Gear & Tools). Nothing is made with it.
  • renamedCategories ({ "template id": { "old": "new" } }): categories renamed in a newer version of the system; players' custom items with the old name get the new one.
  • List them in the order they show, a template's ones after it.
  • The only difference between a system's templates and a player's own is that players can change and delete their own. A player's can go under any template.
{ "id": "arms", "name": "Arms & Armor", "plural": "Arms & Armor", "hue": 5, "icon": "swords", "abstract": true }
{
  "id": "sd-weapon", "name": "Weapon", "plural": "Weapons", "parent": "arms", "icon": "swords",
  "categories": ["Blade", "Club"], "categoryLabel": "Kind",
  "fields": [{ "key": "reach", "label": "Reach", "kind": "text" }],
  "features": { "attack": "main", "equippable": true, "stacks": false },
  "defaults": { "hands": "1" },
  "hide": ["description"],
  "layers": { "identified": {} },
  "triggers": []
}

Files from before templates nested had groups ({ "id", "name", "hue", "templates": [ids] }; a group's split is dropped: the template's categories do that now). They still import: a group of one template becomes that template (with the group's hue); a group of several becomes an abstract template above them. legacyGroups ({ "old group id": "template id" }) says which template an old group is now, so container rules, players' templates and colours that named a group keep working.

Fields

  • kind is one of: text, textarea, number, checkbox, select (with options, optional labels and segmented), tags (with suggestions), dice.
  • Every item also has name, icon, image, cost, weight, description and source, plus the system's commonFields (D&D 5e: rarity).
  • hide removes description or source.

Features (what an item can do) are listed by key, as true (items start with it and each item can change it), "main" (always on, with its fields among the template's own) or false.

  • Core features, available in every system:
    • holds (a container)
    • liquid
    • deck (a set of pieces)
    • pack (unpacks into items)
    • writable (written in)
    • picture
    • equippable
    • stacks (on unless false)
    • charges
    • bundle
  • A system's own features have the same shape:
{ "key": "protection", "label": "Armor", "equip": "worn", "hint": "Worn for armor points.",
  "fields": [{ "key": "armorPoints", "label": "Armor points", "kind": "number" }] }
  • A feature with flag is a yes/no field of the item itself (D&D 5e's attunement).
  • equip says how an item with the feature is equipped: "wielded" (the sword button) or "worn" (the shield button, and "worn" on its row).

Icons: icon is the item icon used when an item has none of its own (see the icon picker for names).

Item types: an item's type is the id of the template it was made from. Items made from a copy of a template also keep template.

States, layers and rules

States are what an item in an inventory can be:

"states": [
  { "key": "attuned", "label": "Attuned", "who": "player", "requires": "attunement",
    "limit": { "value": "attuneSlots", "default": 3, "mode": "warn",
               "text": "You can attune to at most {limit} items", "label": "Attunement slots" } },
  { "key": "identified", "label": "Identified", "who": "gm" }
]
  • who: the player switches "player" states with a checkbox on the item; only the GM switches "gm" ones. The GM can always override any state (On / Off / the player's choice).
  • requires: only items with that feature (or a layer for the state) can have it. requiresProperty: and only those with a property starting with it ("Versatile" for D&D 5e's Two-handed).
  • A campaign can add states of its own (Settings → Campaigns → Item states); the system's can't be removed.
  • limit: how many of a character's items can have the state on.
    • The number is the character's Local value named value (the GM sets it on the character's card, or in Controls → Values), else default. Older files' "gmValue": "gm_attuneSlots" still work.
    • mode is "warn" (allowed, with a warning) or "block".
    • The GM sets each character's limit on their card in the GM tab.

Layers are what an item becomes in each state. An item, or a template, has layers: { stateKey: { … } }:

"layers": {
  "identified": { "name": "Ring of Protection", "cost": 350000, "acBonus": 1, "description": "…" },
  "cursed": { "weight": 10, "locks": ["attuned"], "features": { "charges": true }, "maxCharges": 3 }
}
  • A layer holds only what changes: field values, features it turns on, and locks (states only the GM can change while it's on).
  • The item as it is now is the item plus the layers of its states that are on, in the order the system lists its states.
  • Everything uses the item as it is now: names, weight, value, sale prices, features and panels.

Rules are on items or templates, under triggers:

"triggers": [
  { "when": "becomes", "test": [{ "value": "item.charges", "op": "=", "to": 0 }],
    "do": [{ "set": "item.state.cursed", "to": 1 }], "message": "The wand crumbles to dust." },
  { "when": "while", "every": 1, "test": [{ "value": "item.equipped", "op": "=", "to": 1 }],
    "do": [{ "add": "local.heat", "by": 1 }] },
  { "when": "acquired", "if": [{ "value": "item.state.identified", "op": "=", "to": 0 }],
    "do": [{ "set": "item.state.cursed", "to": "on" }] }
]
  • when:
    • becomes: once, as all the conditions in test turn true (whatever changed them: the player, the GM, a trade, another rule, a value moving over time);
    • while: every every seconds (default 1) while they all hold;
    • acquired: it enters a character's inventory (added, bought, given or traded);
    • equipped, unequipped.
  • A condition: { "value": address, "op": op, "to": … }.
    • op: =, !=, <, <=, >, >=, or between (with to2).
    • to: a number, on or off, one of the value's choices by name, or a formula.
    • Addresses: item.state.<key> (1 on, 0 off), item.charges, item.qty, item.equipped, item.fill, item.worth, item.weight, char.count.<state>, char.limit.<state>, char.stat.<key>, char.carried, local.<name> (the item's Local value, else its character's), char.local.<name>, global.<name>.
  • if: optional conditions that must also hold when it fires.
  • do: actions:
    • { "set": address, "to": … }: a state, charges or a Local value (to as for conditions);
    • { "add": address, "by": … }: charges or a Local value (negative takes away);
    • { "change": "local.<name>", "rate": n, "until": n }: a Local value moving over time (until optional);
    • { "message": text }.
    • Rules can't set Global values: those are the GM's.
  • Formulas: numbers and addresses, + - * / % ^, brackets, and min max clamp abs round floor ceil sqrt: "local.heat / 10".
  • message: shown to the player.

Older triggers still work: { "on": "acquired" | "on:<state>" | "off:<state>" | "equipped" | "unequipped", "if": { "state": key, "is": true or false }, "do": [{ "set": key, "to": true or false }], "message": … }.

Rules run on the device that owns the character, after every change, and while rules on a 4-a-second tick. A chain of state changes stops after 10 steps; a rule firing over and over (two rules undoing each other) is held back.

Values

Global and Local values are numbers the GM sets (see the guide's GM Controls page). A system can declare the ones it uses, to give them a label and a shape in the GM's Values tab:

"values": [
  { "name": "storm", "scope": "global", "label": "Storm", "default": 0, "min": 0, "max": 1, "step": 0.1 },
  { "name": "curse", "scope": "local", "label": "Curse", "choices": ["None", "Uneasy", "Gripped", "Bound"] }
]
  • scope is "global" (one for the whole campaign) or "local" (a character's, or an item's).
  • choices names each step (0, 1, 2…): the GM picks from a list.
  • A state's limit.value is a Local value too (see above).

Equipment slots

Where equipped things go. Each slot has a count, and rules saying what goes in it (as a container's: item, template (and those under it), category, feature, property, name), each taking size places; a rule with a state applies only while that state is on. An item goes in the first slot with a rule it matches. Equipping into a full slot asks what to take out (or to switch off a state that makes something take more room). Without slots, anything can be equipped.

"slots": [
  { "key": "hands", "label": "Hands", "count": 2, "rules": [
    { "feature": "weapon", "property": "Two-handed", "size": 2 },
    { "feature": "weapon", "state": "twoHanded", "size": 2 },
    { "feature": "weapon" },
    { "feature": "armor", "category": ["Shield"] } ] },
  { "key": "armor", "label": "Armor", "count": 1, "rules": [{ "feature": "armor" }] }
]

An item can say its own: slot (a slot's key, or "none") and slotSize.

Characters: stats, campaignSettings and terminology

stats are the character values your panels use, with their starting values: { "str": 10, "con": 10 }.

  • Panels read them as state.character.stats and set them with PackRat.request("setStat", { key, value }).
  • Values can be numbers, short strings, true/false, or short lists.

campaignSettings are the campaign rules settings your system uses. The only built-in ones are D&D 5e's "encumbrance" and "carryMultiplier"; leave it empty otherwise.

terminology renames a few of Pack Rat's own words:

Key Pack Rat says Slot Delve says
character / characters character(s) adventurer(s)
gm GM Referee
gameMaster Game master Referee
rollTable / rollTitle Roll on a table Roll a curio

Money and weight

"currency": {
  "coins": [
    { "key": "gp", "name": "gold", "value": 200 },
    { "key": "sp", "name": "silver", "value": 10 },
    { "key": "cp", "name": "copper", "value": 1, "color": "#c07a45" }
  ],
  "change": ["sp", "cp"],
  "show": "sp",
  "perWeight": 100
},
"weight": { "unit": "slots", "one": "slot" }

Coins

  • List coins largest first. Each is worth value of the smallest, and one coin must be worth 1.
  • Prices (cost) are in the smallest coin.

How money moves and shows

  • change: the coins shops pay out and give change in.
  • show: the coin prices are shown in. Money that isn't decimal shows as coins ("4 sh 2 d"), not fractions.
  • perWeight: how many coins weigh one unit (0: coins weigh nothing).

The party servers are sent these rules when the GM loads a campaign, and work out purchases, sales and change the same way the app does.

Weight: weight.unit names the unit weights and capacities are in; one is the singular.

Catalog, tables and glossary

catalog items are what Catalog → Items lists:

{ "id": "sd-sword", "type": "sd-weapon", "name": "Sword", "damage": "d8", "hands": "1", "cost": 200, "weight": 1 }
  • Ids must be unique and are kept by inventory copies, so don't change them between versions.
  • Items can use any template field and feature field, plus features ({ "liquid": true }), layers and triggers.

tables are roll tables (the roll button in the catalog):

{ "id": "sd-curios", "name": "Curios", "die": "d4", "book": "Slot Delve",
  "entries": [{ "roll": 1, "id": "sd-curio-1" }] }

Each entry's id is a catalog item.

glossary holds tooltips for property tags: { "Finesse": "Use Strength or Dexterity…" }.

starterTemplates are example templates players can add from Catalog → Templates.

showcase: false leaves out the example items a new install's first character starts with (a backpack, rations and arrows, a compass, a totem, a pocket watch, playing cards: see Getting Started). They're made from the system's container, consumable, ammunition, tool and gear templates where it has them.

Panels

Panels are your system's own screens:

  • "inventory" panels sit above the inventory (D&D 5e: Character and Combat; Slot Delve: Adventurer and Weapons);
  • "gm-player" panels give the summary on the GM's player cards.
"panels": [
  { "id": "adventurer", "slot": "inventory", "title": "Adventurer", "html": "<div id=\"root\"></div><script>…</script>" },
  { "id": "referee", "slot": "gm-player", "title": "Referee", "html": "…" }
],
"panelLib": "…script every panel gets first (shared rules)…"

The sandbox: a panel is HTML and JavaScript running in a sandboxed frame. It can't reach Pack Rat's page, storage, cookies or the network; it only gets what Pack Rat sends it. It does get the app's stylesheet, so the app's classes (stats, stat, attack-grid, chip, muted…) look right in light and dark mode.

The PackRat object

  • PackRat.on("state", state => …): called with the character whenever anything changes.
    • state.character: { id, name, stats, coins }.
    • state.items: one per inventory entry, as it is now: { uid, name, qty, equipped, states, values, features, main, template, root, location, inHolder, item, icon }.
    • state.totals: { carried, coinsWorth, gearWorth, coins: [{ key, count }] }.
    • state.limits: per limited state, { count, limit }.
    • state.values: { global: { name: n }, local: { name: n } }: the Global values and the character's Locals (each item's own Locals are its values).
    • state.settings: the campaign's rules settings.
  • PackRat.request(action, args) asks for a change. The app checks each request first:
    • setStat ({ key, value });
    • equip ({ uid, on });
    • setState ({ uid, key, on }, with limits and locks applied);
    • setValue ({ name, uid?, value }): one of the character's Local values, or an item's own with uid; null clears it. Players can't set states' limits (attuneSlots), only the GM can;
    • changeQty ({ uid, delta });
    • openItem ({ uid });
    • openCoins.
  • PackRat.summary(parts) sets the panel's header.
    • Each part is { text, icon?, title?, warn?, closed? } or { coins: true } (the coin purse).
    • Parts marked closed only show while the panel is collapsed.
  • gm-player panels use PackRat.on("compute", state => ({ stats: [{ label, value, title?, sub?, warn? }], chips: [text], notes: [text] })) instead of "state".
  • Helpers: PackRat.h(tag, attrs, ...children), PackRat.icon(name), PackRat.markup(html) (an item's icon markup), PackRat.render(el, ...children), PackRat.fmtMod(n), PackRat.glossary.

Writing panels as functions: the built-in systems write panels as JavaScript functions and put their source in the file ("(" + fn + ")();"), so they can be read and checked like any other code. Do the same, or write the HTML directly.

Clone this wiki locally