Audience: Developers maintaining or extending SoHL. Goal: Identify the safest places to add features with minimal risk.
See also: Architecture Overview, House Rules Cookbook, Security Model.
Choosing extension scope
- Use a Module hook for additive behavior without modifying SoHL source — best for house rules that affect many items or actors.
- Use actions (context-menu entries on a document) for single-item behavior overrides (e.g., one specific spell). A Script Action can also override an intrinsic action on that one document by reusing its
shortcode— see Overriding an intrinsic action.
Lifecycle hooks are emitted with item-type granularity (sohl.<itemType>.<stage>). Filter by item.system.shortcode inside the handler for narrower targeting.
Recommended module guard pattern
When a hook performs persistent side effects, guard it with a world-setting toggle and a GM-only check, so the rule is opt-in per world and runs under a single authority. See the worked recipe in House Rules Cookbook — guard pattern.
Golden rule
Extend by adding new classes and registering them, rather than editing core logic in-place — see the architectural rules and extension mechanisms.
When in doubt:
- Find the nearest existing example.
- Replicate the pattern.
- Change the minimum.
1) System initialization & registration
Primary files: src/sohl.ts, src/core/logic/SohlSystem.ts
Common extension needs: register new settings, sheets, hooks, or document classes.
Guidelines: Keep registration logic explicit and centralized. Avoid side-effect imports.
Registration runs in Hooks.once("init") (settings, system config, calendars, hooks, combat/time defaults, sheet registration), then Hooks.once("ready") (Handlebars helpers, then SohlSystem.ready = true). Read src/sohl.ts for the authoritative order, and add new registration alongside the existing calls there and in SohlSystem.
2) Actor and Item type extension
Actors
Core actor classes: src/document/actor/ with base SohlActor in foundry/.
The per-type split into Document, DataModel, Logic, and Sheet classes — and how they relate — is covered by the three-layer architecture and three-class pattern; read those rather than re-deriving them here.
How to extend: Add logic class in src/document/actor/logic/, data model + sheet in src/document/actor/foundry/, register in SohlSystem.
Items
Core item classes: src/document/item/ with base SohlItem in foundry/.
Same layering as actors.
Worked example (new Item kind):
- Add the kind constant + metadata in
src/utils/constants.ts(ITEM_KIND,*_METADATA). - Create the logic class in
src/document/item/logic/and the data model + sheet insrc/document/item/foundry/. - Register the data model / logic / sheet in SohlSystem.
- Add templates and localization keys.
- Verify
fromData(...), drag/drop, sheet rendering, and context-menu behavior resolve; validate startup.
This is the canonical “add a type” procedure — the Runtime Contracts safe-extension checklist points here.
Avoid: Adding if type === X branches in base classes; prefer polymorphism.
Overriding a Logic class from a module (runtime)
The steps above add a kind in-system. A variant module instead overrides an
existing kind’s Logic class at runtime — no source edits. The base classes are
exposed on the sohl global as sohl.actorLogicClasses / sohl.itemLogicClasses
(kind → base class), and registerActorLogic /
registerItemLogic swap the class used to build every document
of that kind. The resolution path (SohlDataModel.create) already reads that
registry, so no construction sites change.
Register during the module’s init/setup hook, before the first .logic for
that kind is built:
Hooks.once("setup", () => {
class MyBeing extends sohl.actorLogicClasses.being {
// override rules here
}
sohl.registerActorLogic("being", MyBeing);
});
Every being actor prepared afterward uses MyBeing. registerItemLogic(kind, cls) is the item-side equivalent, keyed by ITEM_KIND.
3) Combat / tests / resolution pipeline
Core components:
src/entity/result/*— test and combat result classessrc/entity/modifier/*— value tracking and modificationsrc/document/combatant/— combatant trackingsrc/entity/action/SohlActionContext.ts— request context
Safe extension:
- Add a graded / special-result test as data — see the recipe below — not a new class.
- Add new
*Modifiertypes for new influences. - Keep results serializable for chat/UI.
High-risk:
- Changing shared modifier interpretation rules
- Changing success thresholds or resolution order
Adding a graded / special-result test — pass data, don’t subclass
The most common “new test” need is a d100-vs-mastery-level roll that reports a
bespoke set of outcomes and optionally offers a follow-up action — a
Stumble (“Keeps Footing” / “Stumbles”), a Fumble (“Retains Grip” / “Drops It”),
a Shock test, a Fear test. None of these is a new class. Do not subclass
SuccessTestResult, and do not write a bespoke result card. Drive the single,
well-tested generic path — successTest — and
supply everything bespoke as data in the action scope:
scope.resultDescTable— a result-description table (LimitedDescription[]) that maps each success rung to its label / description / star count. This is the bespoke result text, carried as serializable data.scope.targetValueFunc(optional) — remaps the value the outcome grades against when the test keys off something other than the raw constrained mastery level (e.g. the success-value tests useindex + successLevel - 1).scope.priorTestResult(optional) — reuse an already-rolled result instead of rolling fresh (Fate, GM edits, opposed resume). The die is not re-rolled; see the prior-result seam.
Because you drove the generic path, the test inherits impairment/fatigue
gating, Fate eligibility, priorTestResult reconstruction, and standard-card
rendering with no extra code. A subclass re-implements all of that and drifts
from the one path everyone else fixes bugs in.
Follow-up consent buttons ride the standard card. When a graded result should
offer an action (apply the shock state, record a healing rate), you no longer
need a bespoke postActionCard template. Post the standard card yourself and hand
it buttons — one ActionCardButton or an array:
// Roll the generic test but don't auto-post (`noChat`), then post with a button.
const result = await mlMod.successTest(
new SohlActionContext({
speaker,
scope: { resultDescTable: keepControlTable(winner), noChat: true },
}),
);
if (result) {
await result.toChat({
buttons: {
action: "applyStumble",
handlerUuid: this.uuid,
scope: { priorTestResult: result },
label: sohl.i18n.localize("SOHL.Being.Stumble.apply"),
iconFAClass: "fa-solid fa-person-falling",
},
});
}
toChat folds buttons through the same toRenderableButtons normalizer the
action-card framework uses (scope pre-serialized, skipDialog defaulted), so each
button carries the well-known action-card-button handles and dispatches through
the shared chat-card chokepoint. Nothing auto-fires — the button is offered,
and the target’s controlling player accepts (the consent model). See the
toChat card-data contract.
When you do subclass. Reserve a SuccessTestResult subclass for a test whose
roll math genuinely differs — a different die, a multi-roll resolution, a
non-threshold outcome. New result text or a new follow-up button is never, by
itself, a reason to subclass. AttackResult / OpposedTestResult are the
existing examples of a legitimately different resolution.
See Combat Resolution Pipeline and Modifier Model.
4) Active effects
Core: SohlActiveEffect.
SoHL extends Foundry’s ActiveEffect with an expanded targeting model (targetType / targetName) so one effect can target self, the owning actor, or sibling items by type. See Active Effects for the model and Effects Integration for the full reference.
5) UI: templates and chat cards
- Chat cards:
templates/chat/* - Dialogs:
templates/dialog/* - Actor/item sheets:
templates/actor/*,templates/item/*
Safe extension: Add new templates rather than overloading existing ones. Keep template context objects stable and well-documented.
Adding a chat-card button
Chat-card buttons (inside .card-buttons) and a.edit-action links are routed by
the renderChatMessageHTML hook in sohl.ts, which resolves the handler document
from the clicked element’s dataset and invokes its onChatCardButton(btn) (or
onChatCardEditAction). The dataset attribute precedence is a contract — see
Chat-card dispatch contract.
When adding a new button: emit one of the recognized dataset attributes (do not
introduce a new attribute name), and add an action case to the resolved
document’s handler — e.g. SohlItem.onChatCardButton (see SohlItem), which switches on
btn.dataset.action.
To pass data to the action — a result, a request object — do not invent a new
data-*-json attribute; put the whole payload in one data-scope. The card-creation
logic sets a scope field (scopeData: defaultToJSON(scope)) and the template renders
data-scope="{{toJSON scopeData}}" (the {{toJSON}} helper stringifies it); the
handler revives it with buildActionScope, so the action case reads live
objects off context.scope (e.g. context.scope.attackResult), never a JSON string.
See Chat-card dispatch contract — Passing scope
and the Entity serialization contract.
Cross-actor effects (the acknowledge-button pattern)
Enforce actor state sovereignty: an actor mutates only itself. To make one actor affect another, never reach into the target — instead:
- Resolve the source side on the source. Roll the attack / spell / effect test against the source’s own modifiers and mutate only the source.
- Emit a target-addressed button. Post a chat card whose button carries the target actor’s uuid in
data-handler-actor-uuid(ordata-handler-uuid) and anaction. The label must make the consequence unmistakable (“Acknowledge you fall asleep”). - Apply on the target’s client. In the target’s
onChatCardButtonactioncase, run any required test first (e.g. a resistance roll), then mutate this actor.
Render-time gating makes the button appear only to the responding actor’s owner (the GM owns all). gateAutomatedDefenseButtons (src/document/chat/chat-card-gating.ts) is the reference: it removes a button whose data-handler-actor-uuid actor the current user does not own (actor.isOwner). Reuse this gating for any new target-addressed button.
Working examples already in the tree: automated-combat defense buttons (resolve on the defender’s client) and the createInjury / “Calculate Injury” button (the target wounds itself). Model new mechanics — spells, conditions, knockback, afflictions — on these; do not add a code path where the source writes the target’s state.
6) Localization
lang/en.json
Rules:
- Never rename keys (breaks translations and downstream consumers).
- Add new keys; deprecate old keys slowly if needed.
7) System registries
src/core/logic/SohlSystem.ts — the central registry for CONFIG mappings.
Rules:
- Keep mappings explicit.
- Avoid runtime reflection-based wiring.
- Validate registrations early during init.
8) Event triggers (time, combat, scene-region)
The event queue (sohl.events) dispatches named triggers, and a subscription
runs a document action when one fires — the deferred half of the consent model.
Extension surfaces:
- Subscribe an action to a trigger from a Logic class’s
finalize()(sohl.events.subscribe({ uuid, actionName, triggerName, predicate })) — the built-inupdateWorldTime/ combat-lifecycle triggers, plus the scene-region and environment triggers (regionTokenEnter/Exit/Turn*/Round*,sceneDarknessChange— issue #593). Region triggers are event-driven: nofireAt, sonextFireTimeisundefinedandsystem.lastRunis the temporal query. - The
triggerRegionBehavior (foundry) is the GM opt-in surface: dropped on a region, it forwards curated events into the queue (GM-gated, once) and can offer a region-authored action. High-frequency streams (tokenMove*) are excluded by curation. - Register a custom trigger (registerSohlTrigger + fireSohlTrigger) to add your own lifecycle moment.
All Foundry hook wiring lives in SohlHookBridge — do not call Hooks.on(...)
elsewhere for dispatch. See the Event Queue Reference.
9) Calendar registration
SoHL keeps a registry of calendars that modules can extend; registered calendars
appear in the GM’s calendar settings. The registry API
(SohlSystem.registerCalendar / unregisterCalendar / getCalendar /
applyCalendar / calendars), how to register from a module, and the JSON import
format are documented in the
Calendar Reference.
10) Create-dialog archetypes (flags.sohl.docArchetype)
The shared Create dialog (sohlCreateDialog, used by both SohlActor and
SohlItem) offers an Archetype picker that seeds a new document from an
existing, fully-populated one — so a new Being is born with body, attributes, and
movement instead of blank. Archetypes are data, not code: no source change is
needed to add one.
The contract. Flag any Actor/Item — in a compendium pack or in the world —
with flags.sohl.docArchetype = <priority:number> and it becomes an archetype
for its (type, subType) in the picker. The value is a numeric priority (see
below); a non-numeric marker is ignored.
Identity is the shortcode, not the name. After the (type, subType) filter,
an archetype’s system.shortcode is its identity. Two candidates sharing a
shortcode are the same logical archetype even if their names differ (a
localization, or a diverged world copy) and are deduped to one winner; the name
is presentation only. Keep a shortcode stable and meaningful — same spirit as the
stable-lang-key rule.
Winner selection (only matters on a shortcode collision). Among candidates
sharing a shortcode, the winner is chosen by priority descending, then source
tier ascending (world 0 < system 1 < module 2), then a stable UUID. So a
GM’s world copy shadows a shipped system archetype at equal priority
(no priority fiddling needed), and a module must ship priority > 0 to
override a system archetype (a module left at 0 loses by tier — it cannot
silently clobber a stock archetype). New archetypes with fresh shortcodes always
appear regardless of priority. SoHL ships its stock archetypes at priority 0.
The Foundry-free discovery/resolution helper. The rules above live in the
Foundry-free archetype module and are unit-tested
independently of any dialog: resolveArchetypes
filters by (type, subType), dedups by shortcode, and returns winners sorted
best-first; buildArchetypeOptions turns those into
UUID-valued <option>s (labelled Name (shortcode)) plus (none), defaulting
to the top winner (or (none) when the type has no archetype). The Foundry
boundary that gathers candidates from the world directory and every matching
compendium pack is fvttDiscoverArchetypes in FoundryHelpers.ts.
Archetype-first defaulting (Name/Shortcode). The dialog is laid out
Type → SubType → Archetype → Name → Shortcode, with Name and Shortcode
optional. Selecting an archetype pre-fills Name and Shortcode from its own
name / system.shortcode (live, until you type into a field); leaving them
blank creates a document that matches the archetype — its shortcode is the
archetype’s, subject only to uniqueness bumping (broadsword, broadsword2, …).
Choosing (none) keeps the blank-slate behavior: Name defaults to the class
defaultName and the Shortcode derives from the Name. The resolution rules are
the Foundry-free, unit-tested resolveCreateIdentity
(the dialog only wires the DOM and applies
uniqueShortcode against the taken set).
Shortcode uniqueness (create + update). (type, shortcode) is a unique lookup
key, enforced at runtime across world / embedded / pack scopes — see
Shortcode Integrity. The Create dialog
live-checks the entered shortcode against that scope and disables Create
until it is unique (the _preCreate reject is the backstop). A programmatic caller
opts into automatic key management with the shortcodeDedupe: true create/update
option — a colliding code is suffixed and a name-less create gets a random id, so it
never fails; without it, a collision is rejected. System-generated item creation
(fvttCreateEmbeddedItems, cross-actor gear drops) opts in; the human dialog stays
strict so the author picks a unique code deliberately.
Instantiation strips the flag; copy-verbatim preserves it.
flags.sohl.docArchetype is removed at every point where an archetype is
instantiated into a live document, and kept only when a document is copied as
a library entry. The single stripping primitive is the pure
stripDocArchetypeFlag (it deletes exactly that one
key; other flags.sohl.* are legitimate inherited data).
- Strip — Create dialog seeding from an archetype: a document created from an archetype is not itself an archetype.
- Strip — Drop-to-embed: dragging a compendium or world item onto an
actor/item sheet creates an embedded, in-play child — never a template. The
strip lives in
SohlActorSheetBase._onDropItem, immediately beforecreateEmbeddedDocuments("Item", …), so the flag never rides onto the owner (an embedded item that kept it would pollute discovery and could be re-instantiated as if it were a template). - Preserve — Import and Duplicate of a top-level directory document: these are copy-verbatim operations that yield another library document; preserving the flag is exactly how a GM makes a world-tier override.
Never move the strip into
_preCreate. That hook runs for every create — dialog, drop-embed, directory Import, and Duplicate alike — so it cannot distinguish instantiation from copy-verbatim. If the strip ever migrates there, the world-override workflow silently breaks (Import/Duplicate would lose the flag). The strip must stay at the specific instantiation entry points (dialog + drop), and both sides are guarded by tests.
See Module Development → Archetypes for the module-author recipe.
What to update when you add something
- New actor/item type: Add class, register, add templates, update JSDoc, update docs.
- New user-facing workflow: Add or update the user guide under
assets/packs/journals/_source/(compiled into Foundry journal entries at build).