Skip to content

Module Development

A module teaches Purple Cat something it does not already know — usually a game system, sometimes an asset pack or a set of tools.

This page is an orientation. The authoritative reference is the Module API specification in the repository's docs/shipped/architecture/.

What a module can do

Capability What it means
System definition Declare a game system: its properties, dice, and defaults
Character sheet Project a stored character into the sections and fields the sheet renders
Validation Check a character against the system's rules and report problems
Content Supply spells, feats, monsters, equipment — from bundled data or a remote source
Generation Build monsters and characters from a description
Automation Derive rollable actions from content (an attack that knows its own damage)
Fixtures Seed a campaign with the conditions, damage types, and definitions the system needs
Server routes Register API routes of its own
Assets Ship art

The shape of a module

A module is an npm package that exports a module definition. It declares:

  • an id and a name,
  • the Module API version range it supports,
  • optionally, dependencies on other modules,
  • and whichever capabilities above it implements.

The platform enforces the version range: a module built against an older API is refused rather than half-loaded.

Core principles

Three rules explain most of the API's shape:

  1. The platform is system-blind. Nothing in the platform names your system's fields. If the platform seems to need a special case for your system, the API is missing a seam — that is a bug worth reporting.
  2. The sheet is a projection. Your module turns stored character data into a description of what to render; the platform renders it. You do not ship React components for the sheet.
  3. Content is provided, not imported. The sheet asks your content provider for spells; it does not read your data files.

Getting started

  1. Start from an existing first-party module as a reference — they are the working examples of every capability above.
  2. Implement the smallest useful surface: a system definition and a character projection. That alone gives you a working sheet.
  3. Add content, validation, and generation as you need them.

Where code runs

This catches people out:

  • Sheet projection runs in the browser. It must be synchronous and cannot read the filesystem or fetch a wiki.
  • Content providers and route handlers run on the server, where the filesystem and network are available.

If a projection needs data that only the server can reach, derive it server-side and store it on the character.

Testing

  • Test your projection and validation as pure functions — no browser needed.
  • The platform loads your module's built output, not its source. Build before you conclude a change did not work; this has cost people hours.

Distribution

  • First-party modules are published as packages and listed in the deployment's module configuration.
  • Your own modules can be published anywhere your instance can install from, or developed locally against a running instance.

Fixtures and seeding

A system usually needs campaign-level definitions to exist: conditions, damage types, currencies, property definitions. Ship them as fixtures and the platform seeds them.

Fixtures are versioned. If you change a fixture without bumping its version, existing campaigns will not pick up the change — a common source of "my edit did nothing".

Projecting a token that has no character

The sheet renders a linked token through the character behind it. An unlinked token — a monster dropped on the map, an import that never became a character — has no character to project, and the platform's fallback is the token's flat metadata filtered to whatever keys the campaign declares as property definitions.

That fallback is the platform guessing, from campaign configuration, at a question your module can answer exactly. It is why an unlinked token's sheet feels thinner than a character's: an ability score reads 16 rather than 16 (+3) because a token has no format, attributes land in a group only if a property definition happens to carry one, and nothing is derived at all — no proficiency bonus, no initiative, no passive perception.

Four optional members replace that guess for tokens of your system:

Member On Returns
getTokenAttributes SystemModule the token's attributes, named, formatted and grouped
getTokenTraits SystemModule the token's traits — in practice its skills
getTokenIdentity SystemModule the identity lines above the sheet
computeForToken DerivedStatPanel that panel's rows, per panel

Four rules govern all of them:

  • They take the token's parsed metadata, not the raw token. IToken.metadata is unknown and may arrive as a JSON string; one defensive parse in the platform beats one per module.
  • They must be pure and synchronous. They run during section derivation and again on render.
  • Absence is today's behaviour, so a module that declares none of them is unaffected. An empty result is not absence: "no attributes I recognise" is a real answer, and the platform will not fall back over it. Returning [] from computeForToken drops that panel for this token rather than rendering a heading over nothing.
  • Throwing does not blank the sheet — the platform falls through to its own reading. Do not use it as flow control.

computeForToken is declared per panel rather than as one function on the module because the character-side getDerivedStats flattens every panel into a single list and DerivedStat carries no panel id. A flat token equivalent would arrive with the attribution needed to render two panels separately already gone.

Available since Module API 1.35.0, which is when they were documented; they have worked since platform v0.57.0.

Compatibility

Read the changelog before bumping the Module API version you target. The API is versioned precisely so a module can state what it needs and fail loudly rather than subtly.