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:
- 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.
- 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.
- Content is provided, not imported. The sheet asks your content provider for spells; it does not read your data files.
Getting started
- Start from an existing first-party module as a reference — they are the working examples of every capability above.
- Implement the smallest useful surface: a system definition and a character projection. That alone gives you a working sheet.
- 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.metadataisunknownand 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
[]fromcomputeForTokendrops 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.