Skip to content

Packs and rulesets

A pack is a versioned bundle of game data: an edition’s rules, a sourcebook’s content, a homebrew collection. A ruleset is what the engine makes from one or more packs, and every other engine call takes it. This page covers getting packs, loading them, and keeping rulesets around.

A pack’s release is one JSON document: its manifest, merged rules, content, upgrade scripts and compiled formulas (pack-release.schema.json). That document is all the engine needs. Text lives next to it, one file per language.

Where What you get
npm, @opencharacter/<pack id> The release as the default export, the default language as /i18n/<lang>
npm, @opencharacter/<id>-i18n Every other language, one entry point each (@opencharacter/5e-srd-5.1-i18n/de)
npm, @opencharacter/packs Every official pack’s release, by pack ID
GitHub Releases and jsDelivr The same release.json and language files, for apps that don’t use npm
A homebrew author The same format, under the author’s own npm scope or as a file

Official and homebrew packs load the same way. To your app, a pack is a JSON document you fetched from somewhere.

import srd from '@opencharacter/5e-srd-5.1'; // the release
import en from '@opencharacter/5e-srd-5.1/i18n/en'; // only if you show English

Language files are separate entry points so your bundle includes only the languages you import. See Text and translation.

Every pack declares a license. A CC-BY or ORC pack carries a credit line, and a pack whose license asks for more than one notice lists the others by name in notices. The credit line and every notice must reach your users, the notices after the credit line. All are in the ruleset:

for (const pack of ruleset.packs) {
if (pack.credit) showInCreditsScreen(pack.id, pack.license, pack.credit);
for (const [name, text] of Object.entries(pack.notices ?? {})) {
showInCreditsScreen(pack.id, name, text);
}
}

The 5E SRD pack is CC-BY-4.0, so an app that uses it must show its credit line somewhere its users can find it (an About or Credits screen is the usual place). The orc-2e pack is under the ORC License: its credit line is the attribution, and its notices are orc, reserved-material and expressly-designated. mechanics-only packs carry no rules text by design, but may still have a credit and notices: the FitD packs credit their SRD (CC-BY), and the MÖRK BORG pack carries what its third-party license requires. Show them the same way.

const loaded = loadRuleset([coreRules, sourcebook, homebrew]);
if (!loaded.ok) {
// loaded.errors: [{ code, path, data }], e.g. missing-dependency, formula-error
} else {
const { ruleset } = loaded;
}

loadRuleset takes pack releases in load order and:

  • checks each pack’s spec version against the engine (spec-version-unsupported), and that every dependency is loaded and in range (missing-dependency);
  • merges the rules and content, keyed by qualified ID (pack:id), so packs never collide;
  • applies extends, overrides and hides;
  • resolves the queries that only look at content (“every martial weapon”);
  • parses and type-checks every formula, the same checks the pack’s build ran.

Load errors are for whoever set up the game: a missing pack, a pack too new for your engine, a broken homebrew formula. They carry an error code you can show with the spec’s standard text (Text and translation).

Your app decides the load order. Every dependency has to come before the packs that depend on it, and later packs win when two change the same thing. A sensible order:

  1. the edition’s core rules;
  2. official content packs;
  3. the table’s homebrew, in the order the GM chose.

When two packs override the same property of the same item, or the same setting, the later one wins and the ruleset lists it in ruleset.conflicts ({ target, property?, packs }). Nothing is overridden silently, but nothing is rejected either. Show conflicts to whoever chose the packs (a GM’s campaign settings screen), not to players mid-session.

A character file pins the exact versions of the packs it uses, in load order:

packs:
- { id: 5e-srd-5.1, version: 0.1.0 }
- { id: my-table-homebrew, version: 2.3.0 }

Load exactly those releases, in that order, to compute the character. Two consequences:

  • Keep old versions. A character pinned to 5e-srd-5.1 0.1.0 needs that release, even after 0.1.1 is out. Store releases by id@version and keep any version a saved character still pins. Published versions never change, so a release you’ve fetched once is good forever.
  • Characters can need different rulesets. Two characters at the same table may pin different versions until someone upgrades them. Key your ruleset cache by the character’s packs list, not by game.

If the ruleset’s versions don’t match the character’s pins, computeSheet still computes the sheet and reports pack-version-mismatch in its errors. Treat that as a reason to offer an upgrade, not as a failure.

A campaign usually settles on one pack list, and new characters pin whatever versions the campaign uses. That’s an app decision; the format doesn’t model campaigns.

loadRuleset does real work (merging, resolving queries, type-checking every formula), and its output is plain JSON. So:

  • Load once, call many times. Keep the ruleset in memory for as long as the pack list is in use, and pass it to every call.

  • Cache by pack list. A key like 5e-srd-5.1@0.1.0,my-table-homebrew@2.3.0 works.

  • Skip the checks the build ran. The reference engine skips the formula checks of releases you name in prechecked. Name only releases from your own bundle (the official packages), never one a user gave you:

    import orc2e from '@opencharacter/orc-2e';
    const loaded = loadRuleset([orc2e, tableHomebrew], { prechecked: new Set([orc2e]) });
  • Serializing is fine, within one engine version. You can store a ruleset or send it from a server to clients, as long as the same engine version reads it. The ruleset’s shape beyond its top-level properties is PROVISIONAL and engine-specific, so after upgrading the engine, or in a different engine or port, rebuild it from the pack releases.

Some screens need content the character doesn’t own yet: a class picker, a shop, a spell list. Choices inside content come with their options (Building characters), but “which classes exist” isn’t a choice; it’s a catalog. Today your app reads it from the ruleset:

const classes = Object.values(ruleset.content).filter(
(item) => item.type === '5e-srd-5.1:class' && !item.hidden,
);
// item.qid is the qualified ID to put in an addItem change
  • ruleset.contentTypes lists every content type by qualified name, with its declared fields.
  • ruleset.content lists every item by qualified ID, after extends and overrides, with type (the qualified type name), hidden (hidden by a later pack: leave it out) and deprecated (still valid, but don’t offer it for new characters).
  • An item’s fields are its raw values. Numbers and dice in them are formulas as written in the pack (usually plain literals like 1d8).

The item shape inside the ruleset is PROVISIONAL, so code that reads it may need updating when the engine changes. There is no engine call for catalogs yet.