Skip to content

Showing a sheet

Everything your app shows about a character comes from computeSheet. This page explains its output, how to lay out a sheet from a pack’s display hints, and what to do with competing options, pending choices and rules issues.

const sheet = computeSheet(ruleset, character);

computeSheet never fails. Problems in the character file go in sheet.errors, and the sheet is computed without the broken part.

Output What it is Typical UI
values Every field’s value, with its base and a breakdown, by qualified field ID The sheet itself
effects Every active effect, with its source; stacked-out ones suppressed: true “Active effects” panel, tooltips
actions Actions available now, with the entry each belongs to Roll buttons (Rolling dice)
situational Situational choices offered now (advantage, cover) Toggles next to roll buttons
pending Choices that apply and still need picks “Choose your fighting style” prompts
offers What the rules offer now: derived (level-up) and stored ephemeral offers “Level up”, “Concentration check (12 damage)”
choices Every choice that applies, answered or not, in order A creation wizard’s steps
competing Fields where several effects compete to set the value A picker on the field
issues Rules issues: valid, but the rules object Warnings
copies Each copy’s computed values (an item’s quality), by instance ID Next to the item in the inventory
standardCopies The copies every character holds, and whether the file saves each The inventory, unsaved items greyed out
errors Problems with the file itself (unknown items, wrong types) An import warning

The full shape is in compute-sheet.schema.json. TypeScript types for it, and for every other call’s inputs and outputs, are generated from the schemas: import them from @opencharacter/spec (ComputeSheetOutput, FieldValue, Change, …), or as spec.* from the engine.

Each entry of values is { value, base, breakdown }:

"5e-srd-5.1:combat.ac": {
"value": 17,
"base": 12,
"breakdown": [
{ "op": "set", "value": 15, "source": { "type": "item", "id": "5e-srd-5.1:spells.mage-armor" } },
{ "op": "add", "value": 2, "source": { "type": "item", "id": "5e-srd-5.1:armor.shield", "entry": "inv-3" } }
]
}
  • value is what to show.
  • base is the value from the field’s own formula or input before any effect (10 + Dex modifier here).
  • breakdown lists each effect that changed it, in the order applied: set replaces, add adds, multiply multiplies, atLeast and atMost clamp. Each has a source: an item or template (with the character’s entry when it’s one of their copies), or the rules.

That’s enough for a “show the work” tooltip: AC 17 = Mage Armor 15 + Shield 2. Name each source with its translated name (Text and translation).

A breakdown doesn’t explain the base itself (why the base is 12). The field’s formula is in ruleset.fields[id].formula if you want to show it.

A field can have error instead of value. That happens when formulas loop through a specific combination of items (circular-formula). Show the field as broken. Everything else on the sheet is still correct.

Values are JSON: numbers, booleans, strings, lists (sets of IDs or tags), dice as text (1d8), or null. The field’s type is ruleset.fields[id].type.

The pack’s display hints can name a format for a field (display formats):

format Means
plain As is (the default)
signed Always show the sign: +3, 0, -1
percent As a percentage
dice As dice notation
fraction Decimals as fractions: a CR of 0.25 is 1/4

A field’s format may instead be { bands: <set> }: name the value by a result band set. Find the set’s first band that holds with roll.total the value, and show its name (bands.<set>.<band>.name) beside or instead of the number: a Fate skill of 4 is “Great (+4)”. The set is a qualified ID (fate-core:ladder) in ruleset.rules.bands; the band conditions are formulas you evaluate with the formula API, as the engine never does.

Where the field is an input, its hint may also have input: { min, max, step, widget }, with widget one of number, checkbox, dots, track, text, select. FitD action ratings are dots; FitD stress is a track.

A game-specific app can lay out its sheet by hand and read the fields it needs by ID. An any-game app uses the pack’s display hints: optional data that says which fields belong together and which matter most. They’re in the ruleset at ruleset.rules.display:

const { groups, fields: hints, types } = ruleset.rules.display;
// groups: [{ id: 'abilities', kind?, priority: 0, visibility?, members?, showWhen?, label?, pack }, …]
// hints: { '5e-srd-5.1:abilities.str.mod': { format: 'signed', groups?, priority?, visibility?, rolls?, with?, … }, … }
// types: { spell: { fields: { … } }, class: { state: { level: { priority: 0 } } }, … }

The rules for using them (Display hints):

  1. Groups are sections of the sheet (abilities, combat, skills). A field belongs to a group if its hint lists the group in groups, or if it matches the group’s members query. A field can be in several groups: AC is in both combat and summary.
  2. Priority: 0 is most important, then 1, 2, … Sort groups by priority. Sort fields within a group by their own priority. Ties keep their written order (pack load order last), so every app shows the same order.
  3. visibility says what a view shows: primary values are on every view, compact ones (a token tooltip) included; secondary ones (the default) are on the play sheet and may be collapsed; background ones (base scores, maximums, per-level bookkeeping) stay out of play views and show when the player edits the sheet. A field without one takes its group’s. How much a compact view shows within that is your call.
  4. hidden: true marks in-between helper values. Never show them, not even when editing.
  5. showWhen is a condition: the Spellcasting group hides itself for a fighter. Hide a group or field whose condition is false.
  6. Fields in no group still exist. An “Other” section at the end keeps homebrew fields from disappearing.
  7. unit names a unit: show units.<id>.short after the value (30 ft). Converting units is your call.
  8. rolls makes a field’s tile rollable: each is { action, params? }, a qualified rules action and parameter values to pass to startAction. Start the first on a tap and offer the rest beside it (5E’s Intelligence rolls its check, then its save). Ask for or leave out the parameters a roll doesn’t give (a DC) as when the player starts the action from the action list. Without rolls, you may still infer a roll from an action whose only required parameter is a field.
  9. with puts companion fields in a field’s tile, each { field, as }: a modifier signed after the value (“13 (+1)”), a max as “x / y” (“0 / 195”), a detail on a small second line. In a group where the field has a tile, give its companions none of their own; leave out one whose showWhen is false. When editing, you may split the tile into inputs. State hints take the same, naming state keys ({ key, as }).

Groups also lay out the rest of the sheet. A group’s kind is fields (the default), actions or content:

  • An actions group holds actions from sheet.actions. Its members query is type: action; test its where against each listed action, with item set to { id: listed.action, tags: <the action's tags>, entry?, source? }. 5E puts attacks and spells up front, death saves only at 0 HP, and rests on a menu; FitD keeps downtime apart.
  • A content group holds the character’s copies of one content type (and content picked onto them). Test where with item as the copy’s item and self as the copy. A content group may split its members into sections with sectionBy (5E spells by level: numbers ascending, cantrips first) and sort them with sortBy, then by instance order. A section’s label is groups.<id>.sections.<key>.name if the pack has it, else the key.
  • Content-type hints (types.<type>) say how to show a copy: state.<key> takes the same hints as a field (a class’s level first, its startingClass flag hidden), fields.<name> a format, unit or hidden (a weapon’s damage as dice, its weight in lb), and quantity.hidden drops the count where it never matters.

Only content the character has is listed. A shop or a class’s spell list is your catalog to build from the ruleset.

sheet.activations lists that content for you: every copy, template, granted and picked piece of content, and applied content, in instance order, as { item, entry?, via, scope?, parent?, path? }. via says how it was reached (copy, template, grant, pick, applied), parent is the index of what granted it or owns the choice it was picked in, and a pick’s path is its choice path. A content group’s members are the entries without scope: applied whose item is of the group’s type, so a Mothership Teamster’s skills list shows the two the class grants and the two picked, with no walking of grants and choices on your side.

Group membership queries and showWhen conditions are formulas, and the engine doesn’t evaluate them for you yet. Until it does, evaluate them with the engine’s exported formula module:

import { computeSheet, formula, resolveId, type Ruleset } from '@opencharacter/engine';
type Sheet = ReturnType<typeof computeSheet>;
function test(text: string, ctx: formula.EvalContext): boolean | undefined {
const parsed = formula.parse(text);
if (!parsed.ok) return undefined;
const result = formula.evaluate(parsed.value, ctx);
return result.ok && result.value.kind === 'bool' ? result.value.value : undefined;
}
/** Whether a group or field hint should show. Errors count as "show". */
function shows(ruleset: Ruleset, sheet: Sheet, pack: string, showWhen?: string): boolean {
if (showWhen === undefined) return true;
// Bare field names resolve in the hint's pack, then its dependencies.
const scope = ruleset.packs.find((p) => p.id === pack)?.scope ?? [pack];
const field = (name: string) => {
for (const p of scope) {
const v = sheet.values[`${p}:${name}`]?.value;
if (v !== undefined) return formula.fromData(v);
}
return undefined;
};
// `hasChoice('<type>')`: a choice of the content type, made or pending.
const hasChoice = (type: string) =>
sheet.choices.some((c) =>
type.includes(':') ? c.type === type : scope.some((p) => c.type === `${p}:${type}`),
);
const character = { hasChoice } as unknown as formula.CharacterQueries;
// ID literals (`@spells.eldritch-blast`) resolve in the hint's pack.
const id = (text: string) => resolveId(ruleset, text, pack);
return test(showWhen, { roots: {}, field, character, id }) ?? true;
}
/** The fields a group's `members` query matches; `pack` is the pack that declares the group. */
function members(ruleset: Ruleset, pack: string, where: string): string[] {
return Object.entries(ruleset.fields)
.filter(([id, f]) =>
test(where, {
roots: { item: { id, type: f.type, tags: f.tags ?? [] } },
field: () => undefined,
id: (text) => resolveId(ruleset, text, pack),
}),
)
.map(([id]) => id);
}

Field group membership depends only on the ruleset, so compute it once per ruleset. showWhen, and the members, sections and order of actions and content groups, depend on the character, so evaluate them per sheet.

sheet.effects lists every active effect with its source, target, op and value. Use it for an “active effects” list or for explaining a roll. Effects excluded by stacking (two Blesses don’t stack in 5E) are listed with suppressed: true; show them dimmed or leave them out.

Effects that target rolls (Bless’s +1d4 on attacks) are listed here but apply only when an action rolls, and effects on incoming changes (fire resistance) apply only when damage is applied. Neither shows up in values.

Temporary effects from content the character doesn’t own (a spell cast on them, a condition) live in the character’s applied list, and your app turns them on and off. The engine never tracks time: when Bless ends, remove or toggle it (Changing characters).

A character can be immune to applied content: a monk with Purity of Body can’t be poisoned. The entry stays in the list but has no effect, and issues has an immune issue whose data names the entry ({ entry, item }), so you can show it struck through or offer to remove it.

When several effects try to set the same field, the engine doesn’t decide which wins. It lists them, says which one the rules recommend, and applies that one:

"competing": [{
"field": "5e-srd-5.1:combat.ac",
"options": [
{ "id": "5e-srd-5.1:features.unarmored-defense#0", "value": 14, "source": { … } },
{ "id": "5e-srd-5.1:spells.mage-armor#0", "value": 15, "source": { … } }
],
"recommended": "5e-srd-5.1:spells.mage-armor#0",
"applied": "5e-srd-5.1:spells.mage-armor#0"
}]

Using the recommendation without asking is fine. To let the player choose, show the options and pass the pick back as the third argument:

const sheet = computeSheet(ruleset, character, {
'5e-srd-5.1:combat.ac': '5e-srd-5.1:features.unarmored-defense#0',
});

These picks aren’t part of the character file. Store them yourself (in extensions if they should travel with the character), and pass them on every computeSheet call. A pick that no longer names an option falls back to the recommendation.

sheet.pending lists choices that apply now and still need picks: a new fighter’s fighting style and skills, a level-up’s new spells. Each one has the choice’s path and how many picks it needs (min, max, picked). Show them as prompts; Building characters covers answering them.

sheet.offers lists what the rules offer the character now: a level-up its experience has earned (kind: 'derived', there for as long as that holds) and the moments a change called for (kind: 'ephemeral', stored in the file until acted on or dismissed, or until the state that called for it ends: a concentration check, a death save), or a linked character to make (create: a MÖRK BORG companion’s own sheet). required marks what the rules mandate. Show them beside pending choices; Changing characters covers running and dismissing them.

sheet.copies maps a copy’s instance ID to its computed values: a FitD item’s quality, a cohort’s quality and scale ({ "c-thugs": { "quality": 2, "scale": 2 } }). Show them with the copy, as you show its state. They are never saved, and a value whose formula failed is missing, with a formula-error in sheet.errors. Name each one by types.<type>.computed.<name>.name (types.cohort.computed.quality.name), or by <item>.computed.<name>.name when the item declares it; a state key likewise by types.<type>.state.<key>.name. Leave out a value without a name.

Some games give every character the same items: every FitD scoundrel holds the gear its table tags standard, and one with the Ritual ability holds the ritual it starts with. The pack declares them as standard copies, so the file doesn’t list them until the player changes one. They appear in sheet.values’ formulas, in actions and in the inventory like any copy, and sheet.standardCopies lists them as { entry, item, saved }. Send changes to them by their entry (equip homebrew-gear-lantern, a table’s own standard lantern): the engine writes the copy to the file when it changes, and drops it again once it’s back at its defaults. They can’t be removed.

sheet.issues lists everything the game’s rules object to: a feat whose prerequisite isn’t met, too many attuned items, a rating past its limit, a condition the character is immune to. Each issue is { code, id?, source?, data? }.

Issues don’t make a character invalid. Homebrew tables bend rules on purpose, and legality is checked against the current character, not its history. Show issues as warnings and let the table decide. To turn text into issues, see Text and translation.