Storing characters
The character file is the one standard format for a character. Apps must be able to import and export it, and it’s what the engine reads. How you store characters inside your app is up to you; this page covers what has to survive the trip.
What’s in a character file
Section titled “What’s in a character file”specVersion: 0.1.0id: 7d1c2f4e-5b0a-4c39-9a51-2f7e8a6b3c10packs: # exact pack versions, in load order - { id: 5e-srd-5.1, version: 0.1.0 }values: # input fields, by qualified field ID 5e-srd-5.1:abilities.str.base: 16 5e-srd-5.1:hp.current: 37features: # owned content that isn't carried: classes, traits, feats - id: fighter item: 5e-srd-5.1:classes.fighter state: { level: 3, startingClass: true } choices: fighting-style: [{ option: 5e-srd-5.1:fighting-styles.dueling }]inventory: # carried content - id: inv-1 item: 5e-srd-5.1:weapons.longsword templates: [5e-srd-5.1:magic-items.weapon-plus-one] equipped: trueapplied: # temporary effects from content the character doesn't own - { id: bless-1, source: 5e-srd-5.1:spells.bless, active: true }choices: {} # picks for rules-level choiceshomebrew: {} # the player's own content, if any (below)extensions: # your app's own data com.example.vtt: { portrait: tamsin.png }The full shape is character.schema.json, explained in Characters.
It holds only base state: the values a player typed in, what the character owns, per-copy
state (charges, a class’s level), picks (with their own state, such as a once-a-day feat’s use)
and active temporary effects. Armor Class, modifiers,
attack bonuses and maximum HP are never in the file. They come from computeSheet every
time, so a saved character can’t disagree with the rules, and a pack fix reaches every character
without migrating anything.
Don’t add derived values to the file “for convenience”. If you need them without computing (a search index, a token’s HP bar on a server), keep them in your own cache next to the file (below).
There are two kinds of ID in a character file:
- Content and field IDs (
5e-srd-5.1:weapons.longsword) come from packs. They’re always qualified with the pack, and permanent. - Instance IDs are yours: the character’s
id, and theidof each entry infeatures,inventoryandapplied. Changes refer to copies by instance ID (equip inv-1). They must be unique within the file; the character’sidshould be unique within your app or campaign. A UUID is a good choice for characters. For entries, anything readable and stable works (fighter,inv-1, a UUID). A standard copy, which every character holds (the ritual FitD’s Ritual ability starts with), has an ID the engine derives from its item (fitd-scoundrel-rituals-ritual); the file lists it only once it differs from its defaults.
Two longswords are two entries with the same item and different ids. An entry’s id never
changes, so your app can hang its own data on it (a sort order, a note) in extensions.
Your own data: extensions
Section titled “Your own data: extensions”Portraits, notes, token settings, sort orders and anything else that isn’t game state go in
extensions, under your app’s namespace: reverse DNS (com.example.vtt) or an npm package name
(@example/vtt).
applyChanges(ruleset, character, [ { type: 'setExtension', namespace: 'com.example.vtt', value: { portrait: 'tamsin.png' } },]);- Carry other apps’ extensions along untouched. A character exported from another app may have its data in there, and it must survive import into yours and export back out.
- The engine ignores extensions, except to store them with
setExtension.value: nullremoves your namespace. setExtensionreplaces your namespace’s whole value, so merge before you write.
Changing extensions through applyChanges isn’t required (it’s not game state), but it keeps
them in your change log with everything else.
Custom content: homebrew
Section titled “Custom content: homebrew”A player’s own weapon, skill or ability goes in the character’s homebrew section, not in a
pack your app has to create and ship. The section is a small pack inside the file, so the
character stays self-contained when it moves to another app or to the GM:
packs: - { id: mothership-1e, version: 0.1.0 }homebrew: defaultLanguage: en content: - id: gear.blaster type: weapon # the pinned pack's weapon type: shots and the shoot action come with it fields: { damage: 2d10, magazine: 12, range: long, woundType: fire } i18n: en: gear.blaster.name: Blasterinventory: - { id: inv-1, item: homebrew:gear.blaster, equipped: true }- Refer to it as
homebrew:<id>everywhere else in the file. Inside the section, references resolve in the homebrew first, then in the pinned packs, sotype: weaponis enough. - Nothing else changes. Pass the same ruleset to every call; the engine loads the character’s homebrew on top of it (and may cache that). A homebrew skill joins the class’s skill picks, and prerequisites can name it.
- It can add, not change. Homebrew can’t override or hide the packs’ content or change settings. A table rule that changes the official rules belongs in a standalone pack that the whole table loads (packs and rulesets).
- Errors. If the homebrew doesn’t load (a typo in a type, a formula naming a missing field),
every call returns
homebrew-error, with the load error asdata.causeand apathinto the file (/homebrew/content/0). Show it so the player can fix the item. Its size is capped by the safety limitsmaxHomebrewItemsandmaxHomebrewFormulas. - Editing it is the change
setHomebrew, which replaces the section (nullremoves it). It is refused while the file still uses an item it removes, so remove the item’s copies and picks earlier in the same batch. A batch that starts withsetHomebrewalso repairs a homebrew that no longer loads. - Upgrades rewrite the homebrew along with the saved state: a field, item, content type, item
field, tag or action the pack renames is renamed in it too, each place listed in the report’s
homebrew. What can’t be migrated is in the report’serrors, for the player to fix. - A custom-item form builds its inputs from the content type’s
fields(ruleset.contentTypes[type].fields): the declaration gives each input’s kind (dice,integer,enumwith itsvalues,idorsetwithof),required,default,minandmax, and the pack’s text its label, hint and value names (text.typeField,text.typeFieldDescription,text.value; showing text). Ask for every field: they are all the item author’s, and what changes in play is the copy’sstate, set on the sheet afteraddItem. A field’s display hinthiddenkeeps it off the item’s card, not out of the form; fold hidden and optional fields under “more” if you like. Save with one batch:setHomebrewwith the new item and its name ini18n, thenaddItem. - Sharing an item between characters means moving it into a pack of its own: add it there,
rename
homebrew:<id>to the pack’s ID in each character, and remove it from the homebrew.
pnpm validate checks the homebrew in the example characters as it checks a pack:
mothership-reyes.yaml (a blaster and two
custom skills) and fitd-ash.yaml (a homebrew
special ability).
Storing it in your app
Section titled “Storing it in your app”Anything that round-trips the file is fine:
- A document store or a JSON column: store the file as is. Simplest, and import and export are free.
- Your own tables: fine, as long as you can rebuild the exact file for export, including other apps’ extensions and every pick.
- Files in git: the format is designed for it. YAML and JSON are equivalent.
Whatever you choose, validate imported files against
character.schema.json (the schemas are
published with the spec package and at schemas/0.1/ on this site). Then compute the sheet:
computeSheet never fails, and it lists anything in the file that doesn’t resolve (an unknown
item, a value for a field that no longer exists) in errors, computing the rest.
Change logs, undo and sync
Section titled “Change logs, undo and sync”applyChanges is a reducer: (character, changes) → new character. It never mutates its input,
and the same changes always give the same result. That gives you three patterns for free:
- Undo: keep the previous character. Undo is putting it back.
- History: store each batch of changes with who made it and when. Replaying the batches from a starting file rebuilds the character exactly.
- Multiplayer sync: send change batches instead of whole characters. Every client applies them in the same order and gets the same character. Keep the server’s copy authoritative and let it reject batches that fail.
A batch is all or nothing: a level-up of eight changes applies completely or not at all, so a half-applied level-up can’t end up in your log.
Changes are plain JSON (change.schema.json), so a log is easy to store and send.
Caching derived values
Section titled “Caching derived values”computeSheet computes the whole sheet each time. That’s fast for one character, but some apps
need values for many characters at once: a VTT drawing thirty HP bars, a server answering “who’s
below half HP”, a list of characters with their levels.
The engine has no partial or incremental compute, so cache yourself:
- Compute the sheet when a character changes (after
applyChanges) and keep the values you need next to the file, keyed by characterid. - Throw the cache away when the character, its pinned packs, or the engine version changes.
- Never write cached values into the character file.
Monsters and NPCs
Section titled “Monsters and NPCs”Monsters and NPCs are characters too: a stat block is a character file with mostly fixed values
and a few owned items (5e-goblin.yaml, made by
the 5E pack’s monster creation start). Store a
monster type once as a template file, and give each goblin in an encounter its own copy with its
own id, so damage to one isn’t damage to all.