Skip to content

Character Sheet API 0.1 — De Facto Specification

Status: Descriptive — documents the contract as it ships on branch version/0.43.0 (platform contract skeleton + Play Mode) and the System 8 module version/system8-0.5.0 (reference implementation). Versioned by MODULE_API_VERSION 1.7.0 (there is no separate constant). Quirks and deferrals flagged ⚠︎.

1.6.0 (Character Sheet Overhaul, Phase 0) adds CS-1.6 formatAttribute(attribute, { surface, format }) — pure, synchronous display formatting, null to fall through. It is tier 2 of the resolution chain (campaign-scoped override → module → String(value)), which moves the stock formatters out of seeded renderFunction strings and into module code. The same release makes IPropType the single attribute-type registry: CS-1.2's NumericAttributeFormat is deprecated in favour of a propType reference. See module-api-1.6.md.

Promotes the committed draft 4-system-platform-separation/character-sheet-api.md (retired there) to the as-built reference. Master spec: 4-system-platform-separation/requirements.md (R1).

1. What it is

The Character Sheet API is an SDK, not a REST API. The platform ships generic React sheet primitives and generic logic runners; a system module supplies (a) declarative schemas/vocabularies, (b) pure logic functions (project / validate / suggest / generate), and (c) optional escape-hatch React components — the exact {schema + logic + component} split already used by ArtworkSourceExtension / DiceRollerExtension.

It is not a parallel registry. Every surface is an optional member on the existing SystemModule (systems/types.ts), loaded through the existing SystemModuleExtension.loadModule() and moduleRegistry. A module that implements nothing new still loads and renders a no-validation sheet (today's custom behavior); surfaces degrade independently.

The platform keeps exactly one piece of system knowledge: how to load a module by systemId and render/execute the generic shapes it returns.

2. Version

Ships as part of Module API 1.5.0 (MODULE_API_VERSION, module.ts). All members are additive and optional, so dnd5e-2014 / dnd5e-2024 / system8 / custom keep satisfying SystemModule unchanged. Modules declare the range they target via their engine field; the platform negotiates compatibility from that.

3. The seven-plus-one capability surfaces (CS-1 … CS-8)

All types in systems/types.ts unless noted. All members optional on SystemModule.

CS-1 — Schema & vocabulary (sheetSchema?: CharacterSheetSchema)

The module declares, as data, the character-model vocabulary the generic sheet renders — no platform component contains a system-specific enum or label:

Part Type Declares
CS-1.1 ability scores AbilityScoreSchema ability names + shortAliases, ordered components (label/help/editable), maxBaseScore, level-aware secondaryBonusCapAtLevel, generationMethods
CS-1.2 numeric attributes NumericAttributeSchema[] HP/AC/Speed with format (integer|range|d20-modifier) + editMode (standard|override)
CS-1.3 trait channels TraitChannelSchema[] per generic channel (traits/equipment): subtype taxonomy (TraitSubtypeSchema: label/color/icon + per-subtype renderMode/slotSchedule), catalog flag
CS-1.4 proficiency ladder ProficiencyTierLadder ordered tiers + allowExpertise (absent for systems with no skills)
CS-1.5 derived-stat panels DerivedStatPanel[] { id, label, compute(character) → DerivedStat[] }

⚠︎ Global trait-slug uniqueness (CS-1.3): trait slugs must be unique across all subtypes in a system (a feat and a spell both named "Great Weapon Master" → great-weapon-master-feat / -spell). The module may auto-namespace by subtype (System 8 does — see projection.ts getTraits).

CS-2 — Read projection (construct + accessors)

One set of pure methods, consumed by both the build sheet and the token projection — collapsing the "two pipelines" problem:

construct?(record): Character | Promise<Character>   // choice record → complete manifest
getAttributes?(c): ProjectedAttribute[]              // abilities + numerics, post-derivation
getTraits?(c): ProjectedTrait[]                      // feats/powers/… as generic traits
getActions?(c): IAction[]                            // the platform's existing IAction
getDerivedStats?(c): DerivedStat[]                   // CS-1.5 panels, evaluated

construct turns a persisted choice record into a complete generic manifest (primary attributes + derived attributes/traits/actions the record never stored); the accessors are facets of that manifest. Return types are the platform's existing generic shapes (IAction from vtt.ts, a CharacterAttribute-shaped ProjectedAttribute).

CS-3 — Validation (validate / validateEdit)

  • validate?(c): ValidationIssue[] — whole-character (composes the existing abilityScores.validate* as specializations).
  • validateEdit?(before, change): EditValidation — enforce-mode verdict for a single proposed EditIntent. ⚠︎ Net-new; no Edit-Mode UI wired this project.

CS-4 — Automation / auto-fill (autoFill?: AutoFillEngine)

Line / section / character auto-fill, each returning a CharacterDelta (Partial<Character>) the platform applies. Hard constraint (CS-4.2): autoFill.fillCharacter is the same function the Monster API's generateCharacter composes — one engine, no parallel monster stack.

CS-5 — Content / choice provider (contentProvider?: ContentProvider)

Where character-building content comes from — see the standalone Content API. Consumed via listTraits(type, filter), getTraitChoices(c, slot), listEquipment(filter), listMonsters(filter).

CS-6 — Token projection (projectToken) & action derivation

projectToken?(character): IToken | Promise<IToken> — the single idempotent Character→Token converter; the home for every transform characterToToken.ts hardcodes (standing reactions, attack-bonus, damage tiers + action code, HP/AC, ability map, power→action). CS-2's accessors and projectToken share one interpretation layer, so a sheet Action button is a view of the same derivation the map consumes. Emits the code strings the platform's Action API sandbox executes.

  • Idempotent / deterministic given (character, module data). Determinism is with respect to module-provided content (fetched/cached via CS-5).
  • Derived ⊕ runtime is a MERGE, never a clobber (mergeProjectedToken.ts): preserve x,y/current HP/conditions/instance overrides. Range Current/Max reconciliation — shift Current by the Max delta, clamp [0, newMax] (reconcileRange). Cross-map continuity default: PC = move, monster = spawn (defaultCrossMapContinuity).

⚠︎ Planned change — CS-6 becomes the propagation primitive

Not yet implemented; recorded here so module authors are not surprised. The Character Sheet Overhaul's refactor makes a linked token a one-way projection of its character, which changes when projectToken runs and what the merge preserves. Tracked as tasks P3 / P4 in todo-v2.md.

1. It runs on every character write, not only at creation. Today every call site is a creation path or the explicit "Refresh from Character" button. It becomes the mechanism by which a character edit reaches its placed tokens: PATCH /characters/:id re-projects and writes every linked token, then broadcasts.

2. It must run server-side — and this is now Module API 1.7.0. The fan-out happens in the request path, so the projection has to be callable outside a browser. Task P3a audited it and found no signature change needed: both shipped modules' projectToken is already synchronous and free of window, document, React and fetch. System 8's says so outright — wiki derivation is a server-side backfill job whose output lands on power.actions, keeping the projection pure and deterministic.

The obligation 1.7.0 adds is therefore packaging, not code: a module's SystemModule must be registrable from a server-reachable entry point. Today registerSystemModule is called only from each module's client/index.ts — which contains nothing browser-specific and is "client" by packaging convention — so the server never registers one and loadSystemModule returns null there. Closing that is task P3b.

A module whose projectToken genuinely needs a browser cannot satisfy 1.7.0 and should declare so rather than fail at runtime inside the fan-out.

3. The merge list shrinks to four fields. "Preserve x,y / current HP / conditions / instance overrides" becomes preserve position, facing, vision/light and size — and nothing else. Current HP and conditions become character-scoped, and "instance overrides" ceases to be a concept: under one-way projection a linked token cannot diverge from its character. reconcileRange does not go away, but it applies character-side (raising Max HP must not reset Current) rather than at merge time.

Scope rules and the full token-scoped list: data-model.md.

CS-7 — Escape hatch (CharacterSheetSectionExtension)

module.ts — same shape as ArtworkSourceExtension: { id, label, icon?, moduleId?, component }, props = CharacterSheetSectionProps { character, onChange, authFetch }. Registered via moduleRegistry.registerCharacterSheetSection / getCharacterSheetSections. The sanctioned home for a section the generic model can't express, so the platform never grows a system-specific component again.

CS-8 — Write translation (applyEdit + EditIntent) ⚠︎ types-only

applyEdit?(record, intent): record' is the inverse of construct: the platform issues EditIntents (set-attribute-component, add-trait, remove-trait, adjust-ability) in its own vocabulary; the module decides how each lands as atomic choices. Pairs with CS-3.2. ⚠︎ Types ship, but no Edit-Mode UI or write route is wired — that is the next project.

4. Read-only Play Mode (the shipped consumer)

Character Context renders a read-only Play Mode entirely from the projection (no systemId branches, no createCharacterView):

  • useModuleProjection.ts — loads the module, constructs the manifest cached by (characterId, updatedAt) (manifestCache.ts), and derives attributes/traits/derived-stats + the projected token.
  • ModuleProjectionSheet.tsx — renders attributes / stats / traits (grouped by CS-1.3 subtype) + action buttons that are a view of the projected token's IToken.actions, dispatched through the existing Action API sandbox.
  • Mounted in CharacterContext.tsx behind a "Classic | Projected (beta)" toggle (classic stays default).

5. Proven against System 8 (reference implementation)

In modules/system8/src/ (in this repository since v0.118.0): sheetSchema.ts (CS-1), projection.ts (construct + CS-2 accessors), projectToken.ts (CS-6, P1–P9), validate in system.ts (CS-3), generate.ts autoFill (CS-4), contentProvider.ts (CS-5). 38 module unit tests.

6. Known gaps / deferred

  • ⚠︎ Edit Mode not built. CS-3.2 validateEdit, CS-8 applyEdit, generic write routes, and the reconstruct-on-mutation job flow are types-only; only read-only Play Mode ships. (Next project — Character Sheet Overhaul.)
  • ⚠︎ Old sheet untouched. ui-components/CharacterSheet.tsx and the Add*Modal consumers keep their systemId branches until the old sheet retires next project.
  • ⚠︎ mergeProjectedToken not yet consumed by the re-projection flow (useRefreshTokenFromCharacter still replaces metadata wholesale).
  • ⚠︎ getAttributes (S8) surfaces the six abilities + HP; AC/Speed are declared numeric attributes but not yet computed into the projection.