Engine interface
Every engine, the TypeScript reference engine and community ports alike, exposes the same calls. Ports may follow their own language’s style (naming, exceptions, async), but the inputs and outputs are the JSON shapes in ../schemas/engine/. Each call schema has $defs/input and $defs/output.
Principles
Section titled “Principles”- Pure functions, except dice. No memory between calls, no clock, no file or network access. The app loads packs and passes them in. Dice always go through the swappable dice function, so with a seeded dice function the whole engine gives the same output for the same input. applyChanges rolls only for incoming-change effects with dice, and writes the faces into the changes it returns, so replaying them never rolls.
- Load once, call many times.
loadRulesetresolves inheritance, overrides, content-only queries and versions once. The app then passes the ruleset to every other call. The one load per character is its own homebrew, which every call that takes the character loads on top of the ruleset (engine-core.md); engines may cache it. - Changes work like a reducer:
(character, changes) → new character, or an error. Apps don’t edit character files directly. Actions return proposed changes that the app applies. - Errors are returned as data, never thrown. Ports MAY turn them into exceptions where that is natural.
- The engine never outputs display text, only keys, codes and data. Apps build text keys
from them (i18n.md); the reference helper
@opencharacter/textdoes it for them, outside conformance (the Showing text guide).
What apps own
Section titled “What apps own”The spec leaves these to the app. Packs give the app what it needs for each: an action to run, a field to show, a constraint that names what’s missing.
- The clock: whose turn it is, rounds and initiative, the start and end of a turn, rests, dawn, and how many hours or days pass. Packs give an action for each point in time (a rest, a scene’s end, a legendary-action reset, dawn); the app runs it when that time comes.
- Money a pack doesn’t track: coins, prices and paying for things. A pack that tracks wealth does so with fields and actions.
- Space and range: zones, maps, distance, and who is within an aura’s or a spell’s area. An action’s participants are whoever the app passes; packs give ranges and areas as data.
- The GM’s rulings: who takes a consequence, and the order of rolls in an exchange. Actions take whoever the app names and an opposition as a parameter.
- Creating and linking character files: a new character needs an ID, storage and the player’s say. Packs say when and what with a derived offer naming the empty link slot and the creation start that fills it (
create); the app makes the file, runs the start and saves the link. - Running what the rules offer: the engine lists follow-ups and stores the ephemeral ones in the file, but never runs or applies one, nor rolls outside an action. The app runs an offer, asks first, shows a derived offer later (its “not now”), or dismisses an ephemeral one.
- Sheet layout and catalogs: evaluating display hints (
members,showWhen) and listing content that isn’t a choice. Engines may offer helpers outside the spec.
The calls
Section titled “The calls”| Call | Input | Output | Schema |
|---|---|---|---|
loadRuleset |
pack releases in load order | { ok: true, ruleset } or { ok: false, errors } |
load-ruleset |
computeSheet |
ruleset, character, optional competing-option picks (a list of option IDs), optional links |
values with breakdowns, active effects, available actions, offered situational choices, pending choices, offers, competing options, rules issues, computed values per copy | compute-sheet |
listOptions |
ruleset, character, a choice path, optional links |
options, each with eligible, picked, recommended and reasons |
list-options |
applyChanges |
ruleset, character, changes, strict?, optional links, optional dice function |
{ ok: true, character, changes, issues } or { ok: false, errors } |
apply-changes |
startAction |
ruleset, character, action (id, entry?), params, situational, policy, links?, participants?, answers?, dice function |
finished, paused, or failure | start-action |
continueAction |
ruleset, paused result, answer (faces, option or a handoff’s outcome), policy |
finished, paused again, or failure | continue-action |
upgrade |
old ruleset, new ruleset, character | upgraded character, pending choices, “what changed” report | upgrade |
The ruleset’s shape (ruleset.schema.json) is PROVISIONAL: only its top-level properties are fixed (specVersion, packs, fields, contentTypes, content, rules, conflicts). It is plain data, so apps can cache it or send it between machines running the same engine; another engine should rebuild it from the pack releases.
Besides these calls, checkRelease (upgrades.md, check-release) compares two releases of a pack, each loaded with its dependencies. It is a build-time check for pack tooling, not a call apps make.
computeSheet
Section titled “computeSheet”valuesmaps each qualified field ID to{ value, base, breakdown, tags? },tagsthe field’s tags on the sheet, computed ones included. A field that cannot be computed (a loop) haserrorinstead ofvalue, and everything else still calculates.effectslists active effects; effects excluded by stacking havesuppressed: true.actionslists the actions available now, with the entry each belongs to.situationallists the situational choices offered now.pendinglists the choices that apply and still need picks: the open entries ofchoices.offerslists what the rules offer the character now: its stored ephemeral follow-ups (a concentration check after damage), then its derived offers (a level-up the experience has earned). Each is{ id, kind, followUp, source?, to, character?, action?, params?, changes?, required, cause? }; an app runs one with startAction and itsaction(which carriesfollowUp: <id>), and dismisses only ephemeral ones (engine-core.md).choiceslists every applicable choice, answered or not, in pending order, as{ path, owner, id, slot?, min, max?, picks, open }, inline options’ andactivate: falsechoices included (engine-core.md).competinglists competing options, one entry per competing group with itsopand, for a stacking group,stackKey(see effects.md). The input’scompetingis a list of picked option IDs; each group applies its listed option, else its recommendation.issueslists rules issues.copiesmaps each copy’s instance ID to its computed values ({ <name>: value }), for copies that have any.copyNamesmaps each copy’s instance ID to its name from its state (nameFrom), for copies that have one; apps show it in place of the item’s name.standardCopieslists the character’s standard copies, as{ entry, item, saved }: the rules’ first, then those of active items, so an app can show the unsaved ones without running the queries. Each is also a copy ininventoryorfeatures.activationslists every content activation in instance order, each with its ownerentry, how it was reached (via),scope,parentand, for a pick, its choicepath(engine-core.md). A content group’sfrom: ownmembers are its holder activations of the group’s type.copyLimitslists each content type withcopies, in ruleset order, as{ type, max, perItem, count, entries }(maxandperItemas declared, a formula’s worked out), so an app shows Replace instead of Add (engine-core.md).
startAction and continueAction
Section titled “startAction and continueAction”- A paused result’s
needsnames the step (step, its path) and the action that defines it (action, a qualified ID,<item id>#<action id>for an inline action). Every log entry has both too. - A dice pause’s
needs.summaryand every roll log entry’ssummarysay what the roll is rolled against: the band set’s bands that can still hold with their conditions resolved, structuredtestsand the running action’s flags. An app previews a roll by starting the action withdice: 'pause'and readingneeds.summary(engine-actions.md). - Each of a finished result’s
proposedChangesis{ to, character?, step, change }:stepis the path of thechangesstep that proposed it.toisself,target, a role of the action or{ link }(a qualified slot ID). A change for a link carries the linked character’s ID ascharacter, and one for a role its participant’s viewid. The app applies each change to that character. participantspasses the other characters taking part, by role the action declares: per role, a list of views{ id, values }taken from their owncomputeSheet, read like a link’s. An undeclared role or a count outside the role’sminandmaxisinvalid-participants, a repeated IDduplicate-id, and a value of the wrong typeinvalid-link-value.- A handoff pauses with
needs.kind: handoff, theparticipant({ role, id }) andrun({ action, params }): the app runs that action on the participant’s own character and answers with its finishedoutcome({ result?, data? }). Aresultthe used action doesn’t declare isanswer-mismatch. The participant’s own proposed changes and log stay with the app. - An allocation pauses with
needs.kind: allocateandneeds.allocate(the offered fields with their values and limits, and the budget or the values); the app answers with{ amounts }, a map of field ID to amount. - A paused action keeps the link views and participants it started with, so
continueActiontakes none. answersreplays a list of answers, one per pause, each as continueAction takes it: the result is startAction then continueAction with each in turn. An answer that doesn’t fit, or one left over once the action finishes, isanswer-mismatchwithdata.indexand, for a pause,data.needs. An app that can’t trust a pausedstatefrom a client keeps only the answers (engine-actions.md).- An outcome
resultoutside the action’s declaredresultsisundeclared-result. - Text keys for prompts, parameters, rolls and results are built from
actionand the step’s ID (i18n.md); the engine outputs none. Details are in engine-actions.md.
computeSheet, listOptions, applyChanges and startAction take optional links: the views
of the characters filling the character’s link slots, by qualified slot ID, each
{ id, values }. The app takes values from that character’s own
computeSheet and may pass all of them; the engine reads only the declared fields. upgrade
takes none: it changes saved state only. See engine-core.md.
listOptions
Section titled “listOptions”listOptions shows ineligible options with their reasons rather than leaving them out; the app decides whether to hide them, grey them out or allow them. PROVISIONAL, to be revisited once seen in practice. Each reason is exactly the rules issue applyChanges would report if the option were picked anyway.
Three kinds of problem
Section titled “Three kinds of problem”| Kind | When it’s caught | Example | Result |
|---|---|---|---|
| Shape error | Before running, from the change’s type (change.schema.json) |
equip with no entry; a number where text belongs |
Rejected |
| Runtime error | While applying, against the current character and ruleset | equipping an item the character doesn’t own; an unknown ID; Strength set to "hello" |
Rejected; character unchanged |
| Rules issue | After applying | a feat requires Str 13; too many attuned items | Applied and reported (rejected in strict mode) |
A runtime error means the result would not be a valid character file. A rules issue means the file is valid, but the game’s rules object.
- Errors (
error.schema.json):{ code, path?, data? }, wherecodecomes fromdata/error-codes.json. Each code has akind:shape,runtime,loadoraction. - Rules issues (
rules-issue.schema.json):{ code, id?, source?, data? }, wherecodecomes fromdata/rules-issue-codes.json.idis the qualified pack-defined prerequisite or constraint ID. - Strict mode (
strict: trueonapplyChanges) turns rules issues into a runtime error (rules-issue, with the issues attached). Rules issues don’t block by default because homebrew tables bend rules on purpose. - The spec ships standard English text for every code in
../i18n/en.yaml(errors.<code>,issues.<code>), exported as@opencharacter/spec/i18n/en(JavaScript and JSON; i18n.md).
Changes
Section titled “Changes”The change types are a small, generic, fixed set (data): set, adjust, addItem, removeItem, equip, setState, adjustState, setQuantity, adjustQuantity, applyTemplate, removeTemplate, applyEffect, removeEffect, toggleEffect, choose, unchoose, setExtension, finishCreation, setHomebrew, dismissFollowUp and clearFollowUp.
- Batches are all or nothing. A level-up of eight changes either fully applies or doesn’t apply at all.
- Same changes, same result. Replaying a character’s change list from the start always rebuilds the same character, which gives apps undo, history and multiplayer sync.
- Apps log the output’s
changes, not their input. It is the batch as applied: anadjustwhose incoming-change effects rolled dice (MÖRK BORG armor) carries the faces it used infaces, so a replay reads them back and never rolls. Without faces or a dice function such a batch is rejected withfaces-required, listing the dice to roll; the app can roll them and resend the batch withfaces(engine-core.md). - An
adjustchange withtagspasses through the character’s incoming-change effects first (resistance halves tagged fire damage), then through absorbing fields and overflow (rules-and-fields.md). The output’sadjustmentslists what each adjust actually did to each field. - An
onorbecomesfollow-up the batch triggers (a concentration check after damage, a death save at maximum wounds) is stored in the returned character’sfollowUps, and the output’sfollowUpslists the ones this batch stored, as computeSheet’soffersdoes. applyChanges never runs or applies them; the app decides (engine-core.md).dismissFollowUpdeletes one unrun;clearFollowUp, which startAction proposes for a run that takes one up, deletes it when the run’s changes are applied. unchooseremoves the option’s nested picks in the same change. Withoutoptionit removes every pick of the choice, with their nested picks; a choice with none is left unchanged, so a batch can clear a choice and pick again without knowing what it held.finishCreationclears the character’screating. It is rejected withcreation-incompletewhile the character has rules issues or pending choices, which the failed result lists asissues(pending choices astoo-few-picks).setHomebrewreplaces the character’s own homebrew, or removes it withnull; later changes in the batch see the new one (engine-core.md). Actions can’t propose it.setQuantityandadjustQuantitychange a copy’squantity(spending an arrow, buying ten more). It never goes below 0, and a copy at 0 stays: removing it is a separateremoveItem.setState,adjustState,setQuantity,adjustQuantity,equipandremoveItemmay name their copies with a query (copies: { type, where }) instead of oneentry. The receiving character resolves it, so an action can aim one at its target’s copies (engine-core.md).applyEffectmay leave outid, andremoveEffect,toggleEffect,setStateandadjustStatemay name applied entries with a query (applied: { where, limit? }). The receiving character picks the ID and resolves the query, so an action can apply a condition to its target, or end or worsen one, without knowing the target’s file (engine-core.md).
Typing at two levels. The change itself is checked against the spec’s JSON Schema. What’s inside it (does abilities.str.score exist; is 25 within its range) is defined by the ruleset and checked at runtime. Tooling can generate game-specific types (TypeScript, JSON Schema) from a ruleset, so an app built for one game catches most runtime errors before anything runs.
Safety
Section titled “Safety”Engines enforce the safety limits and report safety-limit-exceeded instead of hanging or exhausting memory.