Sheet-System Plugin API

Describe a tabletop RPG's classes and level-up rules as data, and the built-in Character Builder turns it into dropdowns, level-up choices, and a rendered character sheet.

description

Sheet-System Anatomy

Pure Data, Same Folder

A sheet-system plugin lives in the same campaign plugins/ folder as regular Fey-Gate plugins, but is pure data — no onLoad, nothing executes against the running app. The header comment adds a type: sheet-system line so the loader tells it apart from a VTT plugin:

/*! feygate-plugin
name: My System
description: A tabletop RPG's classes and level-up rules.
version: 1.0.0
type: sheet-system
*/

export default {
  id: 'my-system',       // stable, kebab-case, unique
  name: 'My System',
  version: '1.0.0',
  description: 'A tabletop RPG\'s classes and level-up rules.',
  apiVersion: 1,          // the sheet-system API major version you target
  classes: [ /* SheetSystemClass, see below */ ],
};

The Character Builder Drives It

The in-app Character Builder walks your classes data to render creation and level-up dropdowns. A core-side renderer turns a player's resolved choices into a normal markdown-plus character sheet — the same format hand-authored sheets use — so every system's sheets look consistent, and dice notation like 1d4 in a feature description becomes a clickable roll for free.

Trust

Reviewed and approved the same way as any other plugin (hash + per-device consent in Settings). Because sheet-system plugins never execute code, the risk is limited to the data itself — there's nothing to sandbox.

schema

SheetSystemClass

Profile Fields

Each entry in classes is a SheetSystemClass: a stable kebab-case id, a display name, and a fixed profile shown at the top of the rendered sheet — keyStats (string array), hitDie, startingHp, the optional startingWounds, and the optional saves, armor, weapons, startingGear (string array).

{
  id: 'berserker',
  name: 'Berserker',
  keyStats: ['STR', 'DEX'],
  hitDie: '1d12',
  startingHp: 20,
  startingWounds: 6,
  saves: 'STR+, INT-',
  armor: 'None',
  weapons: 'All STR weapons',
  startingGear: ['Battleaxe', 'Rope (50 ft.)'],
  levels: [ /* SheetSystemLevel[], see below */ ],
  pools: { /* named ChoicePools, see below */ },
}

Hit Dice & Wounds — automatic trackers

Core always renders a green Hit Dice bar below Hit Points, sized to build.level (one per level, Nimble's default) at hitDie's size — no extra field needed, since every class already sets hitDie. Setting the optional startingWounds additionally renders a red Wounds bar starting at 0/startingWounds; omit it for a system with no Wound mechanic and the sheet simply has no Wounds tracker, same "absent means not shown" rule as manaFormula. Both are ordinary {bar:Name:current/max:color} markdown-plus fields (see manaFormula below) — current is never computed from feature grants (e.g. a "+4 max Wounds" ability stays prose), same as Hit Points today.

levels

levels is an array of { level, grants }, one entry per level that grants something. Levels need not be contiguous or start at 1 — the Character Builder only offers the next level actually present in your data.

pools

pools is a map of pool id → ChoicePool (a name plus a list of named options). A choice grant references a pool by id; the Builder never offers an option that was already picked anywhere on the build.

checklist

Grant Kinds

feature — no decision

Fixed text shown as-is: { kind: 'feature', id, name, description }. Put dice notation directly in description (e.g. "Roll a 1d4") — it renders clickable automatically.

choice — pick from a pool

{ kind: 'choice', id, prompt, poolId, count }. The Builder renders count dropdown(s) sourced from pools[poolId].options, excluding options already chosen anywhere on the build. If the picked option has a featuresByLevel entry for the level the choice was made at, those features are granted immediately alongside it — useful for a subclass pick that unlocks its first tier of features on the spot.

ChoicePoolOption.revokes — losing earlier or later abilities

A pool option may declare revokes: { grantIds?, spellIds? } for the case where picking it narratively takes something away — most commonly a subclass-swap option (e.g. a "story-based" subclass a GM grants mid-campaign in place of an earlier one) that drops a fixed feature, an earlier subclass's own features, or spell access the character already had. grantIds names other Grant ids on the same class (any kind, any level, earlier or later in the level track) — once the revoking option is picked, those grants stop resolving or displaying (via resolveLevel/resolveGrant), and if not yet answered, the Builder stops asking for them (via unresolvedGrants) — so a subclass swap can retroactively remove an earlier pick and pre-empt a not-yet-reached one in the same field. spellIds excludes specific spells from knownSpells even though a spellAccess grant's school/tier would otherwise include them. It applies the moment the option is picked, regardless of the build's current level. There's no matching "grant a specific spell" field — a replacement ability is just prose in the option's own featuresByLevel text, the same as every other narrative effect here.

{
  id: 'oathbreaker',
  name: 'Oathbreaker',
  description: 'Falls from the light but not fully into the dark…',
  featuresByLevel: {
    3: [{ name: 'Dark Benediction', description: 'Trade 3 Radiant spells for 3 Necrotic ones…' }],
  },
  // Loses a level-2 feature the base subclass never touches, plus the 3
  // named Radiant spells Dark Benediction's own text says are traded away.
  revokes: {
    grantIds: ['lvl2-paragon-of-virtue'],
    spellIds: ['true-strike', 'heal', 'warding-bond'],
  },
}

statIncrease — pick a stat to bump

{ kind: 'statIncrease', id, prompt, stats, count }. Renders as count dropdown(s) over the given stat names; resolves to a single "+1 X[, Y]" line.

subclassFeature — automatic follow-up

{ kind: 'subclassFeature', id, poolId }. No new decision — it looks up whichever option was already picked from poolId by an earlier choice grant, and grants that option's featuresByLevel[level] entry, if any, for the current level. This is how a class like Nimble's grants "your next subclass feature" at later levels without asking again.

spellAccess — automatic, whole-school unlock

{ kind: 'spellAccess', id, schools, maxTier }. No new decision — the character automatically knows every spell in schools whose tier is at most maxTier. schools is either a fixed string array, or { fromChoice: grantId } to resolve against the option id(s) picked by an earlier choice grant (same pattern as subclassFeature's poolId lookup) — useful when a class picks its school at level 1 and then unlocks tiers in it over time. Multiple spellAccess grants for the same school accumulate: the highest maxTier ever granted wins, so a later level simply raising the ceiling just works.

auto_stories

Spells

SpellDef — the system-wide spell list

A plugin with any casters declares a top-level spells: SpellDef[] (empty for non-caster systems). schools and tier are opaque to the core — a plugin defines its own school names and how many tiers it uses (0 = cantrip/at-will is convention only). Optional classIds locks a spell to specific class(es); omit it to let any class whose known schools cover it learn it.

{
  id: 'firebolt',
  name: 'Firebolt',
  schools: ['fire'],
  tier: 0,
  description: 'Deal 1d10 fire damage to one target.',
}

Two ways to know a spell

Most casters simply know every spell of a school up to a tier they've unlocked — that's spellAccess (see Grant Kinds above), declarative and automatic. A smaller "pick one by one" track (e.g. utility cantrips) is just an ordinary choice grant against a pool built with the spellPool() helper below — no new mechanism, the existing dedup-across-the-build behavior applies exactly like any other choice pool.

spellPool() — a pool sourced from the spell list

A plugin-authoring convenience (not a runtime type) that filters the system's spells into a ChoicePool, so a per-spell pick references the shared list instead of duplicating spell text by hand: spellPool(id, name, spells, { schools, maxTier, classId }). Since a plugin module can't import from app source (it's loaded from a Blob URL), copy this small filter function into your own plugin file — see examples/plugins/nimble.js for a working copy.

manaFormula — shown, never computed

A caster class may set manaFormula (free text, e.g. "(INT x 3) + LVL") on its SheetSystemClass. Core prints it verbatim at the top of the rendered Spells tab and never parses or computes it — same "core renders, plugin supplies data" rule as everything else here. Immediately below it, core also renders an interactive blue mana bar (a markdown-plus {bar:Mana:current/max:#3b82f6} field).

manaMaxFormula — the computed companion to manaFormula

Set manaMaxFormula: { stat, multiplier? } alongside manaFormula to seed that mana bar with a real starting max instead of 0/0: core computes (statValue × (multiplier ?? 1)) + build.level, using the same resolved stat values computeSkillTotal uses. stat must match one of the system's stats entries. This only covers the "(stat × N) + level" shape every Nimble caster uses — omit it (keep only the free-text manaFormula) for a class whose mana scales some other way; the bar still renders, just starting empty.

Rendering: the Spells tab

If a class has no known spells (no spellAccess grants resolve to anything and no spell was individually picked), the rendered sheet is unchanged — no tabs, identical to a non-caster today. Once a build knows at least one spell, the sheet splits into a Character tab (the usual profile/attributes/skills/features) and a Spells tab (mana formula, then known spells grouped by school and tier) using markdown-plus's existing :::tab Name ... :::endtab syntax — dice notation inside a tab is auto-linked exactly like anywhere else on the sheet.

target

Attributes & Skills

System-Wide, Not Per-Class

A plugin declares four more top-level fields, shared by every class in it: stats (the stat names, e.g. ['STR', 'DEX', 'INT', 'WIL']), statArrays (pre-built point arrays a player picks one of), skills (each tied to one governing stat), and bonusSkillPoints (extra points freely distributed at creation).

{
  stats: ['STR', 'DEX', 'INT', 'WIL'],
  statArrays: [
    { id: 'standard', name: 'Standard (+2, +2, +0, -1)', values: [2, 2, 0, -1] },
    { id: 'balanced', name: 'Balanced (+2, +1, +1, +0)', values: [2, 1, 1, 0] },
  ],
  skills: [
    { id: 'might', name: 'Might', stat: 'STR' },
    { id: 'stealth', name: 'Stealth', stat: 'DEX' },
    // ...
  ],
  bonusSkillPoints: 4,
  classes: [ /* ... */ ],
}

Assignment Is By Slot, Not Value

A StatArray's values can repeat (Nimble's Standard array is [2, 2, 0, -1] — two stats get +2). The Character Builder tracks which array slot (index) went to which stat in CharacterBuild.statAssignments (stat name → slot index), not which value, so two identical values can go to two different stats without collision.

Skill Totals Are Always Derived

A skill's modifier is never stored directly — it's assigned stat value + CharacterBuild.skillPoints[skillId], computed fresh on every render. Change a stat assignment later (the choice editor allows this) and every skill using that stat updates automatically, the same way a subclass swap ripples through its subclassFeature grants.