How to build a Foundry VTT module that integrates with or extends Song of Heroic Lands (SoHL) without modifying the system itself.
A module is the right vehicle for complex house rules, added content and automation, alternate rulesets, and re-skins — anything you want to ship and toggle independently of the core system.
The guiding principle is the same one the system holds itself to: extend, don’t fork. SoHL exposes hooks, registries, a public runtime object, and CSS variables precisely so a module can layer on top. Editing system source is not an integration strategy — when the system updates, your changes are lost and your users’ worlds are at risk.
How a module attaches to SoHL
SoHL is a Foundry system with id sohl (see system.json), requiring Foundry
v14+. A module targeting it declares that relationship in its own
module.json, so Foundry knows the module is meant to run under SoHL:
{
"id": "my-sohl-module",
"title": "My SoHL Module",
"compatibility": {
"minimum": "14"
},
"relationships": {
"systems": [
{
"id": "sohl",
"type": "system",
"manifest": "https://github.com/HeroicLands/Song-of-Heroic-Lands-FoundryVTT/releases/latest/download/system.json",
"compatibility": {
"minimum": "0.7.0"
}
}
]
},
"esmodules": ["scripts/my-module.js"]
}
(See Foundry’s Module Development
article for the full manifest schema.) Your module’s esmodules entry script
then hooks into the system at runtime — a minimal skeleton:
// scripts/my-module.js
Hooks.once("init", () => {
console.log("my-module | init");
// Register settings, keybindings, etc. here.
});
Hooks.once("ready", () => {
// `sohl` and every document's `.logic` are available from here on.
// Extend behavior with SoHL lifecycle hooks (see below) — never edit core.
Hooks.on("sohl.mysticalability.postFinalize", (item, ctx) => {
// your house rule
});
});
Reaching the SoHL API
A module reaches SoHL through the same two surfaces any script uses — the global
sohl object (system-wide services and helpers) and each document’s .logic
(per-document state and actions). See The SoHL API
for both surfaces and the SohlSystem member reference. For type-safe module
development, install the generated types package
(npm install -D @heroiclands/sohl-types) and reference it in your tsconfig.json
("types": ["@heroiclands/sohl-types"]) — it types the sohl global and exports
the public class/interface types for annotations (see
API Access Map).
The reachable public API is still being formalized (epic #80). Treat the canonical how-to references below as the source of truth for exact registration calls, and prefer hooks for behavior you want to be stable.
Extension mechanisms
SoHL supports all of the standard hooks provided by Foundry VTT. In addition to these hooks, SoHL also supports a number of other hooks that can be used to integrate with the system.
Lifecycle hooks — cross document behavior
When a document is processed by SoHL, it goes through various lifecycle methods. Hooks have been developed to allow you to add your own processing at these critical points. This allows you to add or alter the behavior of skills, gear, or other items uniformly across all documents. See Lifecycle Hooks for the full hook reference, the arguments each receives, and the recommended module guard pattern (a world setting toggle plus a GM guard) so your module is opt-in and side-effect-safe.
Registries — calendars
The system keeps registries a module can add to during init, including the
calendar registry. The exact registration calls live in
Extension Points (§7 System registries,
§8 Calendar registration) — follow that document so your code matches the
maintained API.
New types, sheets, effects, and chat cards
Adding a new actor/item type or sheet follows the system’s layering pattern (Document → DataModel → Logic → Sheet). Extension Points walks through registering new types, active-effect integration, and UI/chat-card templates. Read Architecture Overview first for the three-layer model and the Foundry-free logic boundary your additions must respect.
Scheduling deferred actions
A module can schedule an action to come due at a future world time — a
recurring “check for bandits” roll, a wound’s next healing test, a seasonal
upkeep prompt. This is the generic scheduler (issue #588). It never performs
the action on its own: when the schedule comes due, SoHL posts a [Perform]
reminder card addressed to the document’s owner, and only their click runs it.
That is the Prime Directive in mechanism form —
reminding is allowed; performing is not.
1. Put the action on a document. The thing you schedule is an ordinary SoHL
action — an actionDefs entry on the host document’s system, typically a
Script Action referencing a permission-gated Macro (see
Macros and Actions; never inline code into
data). Attach it programmatically with sohl.addScriptAction rather than
hand-writing the actionDefs array:
// bind a permission-gated Macro as a runnable SCRIPT action (GM only)
await sohl.addScriptAction(host, {
name: "checkForBandits", // becomes the action shortcode AND default title
executor: macro.uuid, // a Foundry Macro UUID — a reference, never code
});
sohl.addScriptAction(doc, spec) fills the same defaults as the sheet’s “create
action” control, so you supply only { name, executor } (plus optional title /
scope / iconFAClass / group / minActorOwnership / trigger / visible).
The name you pass is the identity sohl.schedule and the [Perform] reminder
key on, so keep it stable; re-attaching the same name replaces the entry, so
an init hook is safe to run on every reload. Authoring a SCRIPT action is GM-only
(it is a no-op for a non-GM), matching the security rule.
The host must be a document that carries system.scheduledActions, which means
one whose data model extends the base SohlDataModel: an actor or an
item. Scenes and active effects extend TypeDataModel directly and cannot
host a schedule.
2. Schedule it with sohl.schedule, from any owner of the document:
// interval is in seconds of world time; payload is opaque scope handed to the
// action when the owner clicks [Perform].
await sohl.schedule(item, "healingTest", 24 * 60 * 60, { severity: 3 });
sohl.schedule(doc, actionName, interval, payload?) does two things atomically:
persists the schedule to doc.system.scheduledActions (the durable record,
anchored at the current world time) and arms the live event queue. Both halves
derive the fire time from the same anchor + interval, so they cannot drift. To
make a schedule recurring, call sohl.schedule again from inside the action
after it performs — each occurrence schedules the next. sohl.unschedule(doc, actionName) removes both halves.
Consent (issue #579): offer, don’t auto-re-arm. A recurring action must not silently reschedule itself — that would make the system act without a human. When due, the queue posts an owner-gated
[Perform]reminder (it never runs the action); on the click the action performs, then offers the next occurrence and only schedules it on a yes. The core effects use the sharedofferSchedule(context, doc, actionName, interval)helper (acontext.scope.scheduleanswer, else a private default-No dialog); a module action can callsohl.scheduleconditionally on its own confirmation. Reserve unconditional re-arming for genuinely ambient, GM-only world-host clocks (e.g. a bandit check).
3. World-wide actions have no natural document to hang off. Use the singleton world host actor:
const host = await sohl.worldHost(); // find-or-create the `sohlworld` actor
if (host) await sohl.schedule(host, "checkForBandits", 6 * 60 * 60);
sohl.worldHost() returns the reserved sohlworld actor, creating it (GM only,
ownership NONE so players never see it) if absent. Because it is an actor it
already has the execution surface a [Perform] needs. Pass { visibility: "gm" }
in the payload to whisper the reminder to the GM only — no metagame leak for
world events.
4. Scene-scoped actions — most events belong to a place (bandits at a
hideout, a hazard on a caravan path), not the whole world. Pass a sceneUuid and
the schedule fires only while that scene is the active scene:
await sohl.schedule(
host,
"checkForBandits",
4 * 60 * 60,
{ visibility: "gm" },
scene.uuid,
);
sohl.schedule(doc, actionName, interval, payload?, sceneUuid?) — with a
sceneUuid, a check that comes due while the party is elsewhere is held, not
lost: it surfaces the moment they return to (re-activate) that scene, and no
occurrences accrue while away. Omit sceneUuid (as in the examples above) for a
genuinely world-wide clock — a plague, a season — that fires regardless of the
active scene. A schedule on an unlinked token’s actor is naturally scene-scoped:
name its token’s scene.
The reload path is automatic. The event queue is rebuilt from scratch each
session, so on the ready hook SoHL re-arms every world actor and its embedded
items from their persisted system.scheduledActions (scene binding included). You
schedule once; it survives reloads without any further wiring on your part.
Triggering on a scene region (issue #593)
Beyond time, an action can fire on where a character is. SoHL bridges Foundry
v14 scene-region events into the same queue, so the consent path is identical — a
region event surfaces an owner-gated [Perform] reminder, never a fait accompli.
There are two ways to author it, and neither modifies system source:
From the GM’s chair, no code. Drop a “SoHL Event Trigger” RegionBehavior onto a region, tick the events to forward (
Region: Token Enters, …), and name an action to offer (a shortcode on the entering actor). Entering the region offers that action to the token’s controlling player.From a module, per character. Subscribe an action to a region trigger and scope it with a predicate — the region events are
regionTokenEnter/regionTokenExit/regionTokenTurn{Start,End}/regionTokenRound{Start,End}, plussceneDarknessChangefor environment:sohl.events.subscribe({ uuid: actor.uuid, actionName: "fearCheck", triggerName: "regionTokenEnter", predicate: new sohl.entity.expr.SafeExpression({ source: "regionId == 'crypt'", }), });The action receives the region context (
regionId,tokenUuid,actorUuid,sceneUuid) as its scope. These triggers are event-driven:nextFireTimeisundefined; useactor.system.lastRun[actionName]for “when last?”. The GM must still place a “SoHL Event Trigger” behavior on the region for the events to flow — that is the human-behest opt-in. High-frequency streams (tokenMove*) are deliberately not exposed. See Event Queue → Scene-region triggers.
Shipping Create-dialog archetypes
The SoHL Create Actor / Create Item dialog offers an Archetype picker that seeds a new document from a fully-populated template. Archetypes are pure data — a module contributes them without any code.
Specify a new archetype. Ship an Actor or Item in your module’s compendium pack with two things:
flags.sohl.docArchetypeset to a number (its priority), and- a stable, meaningful
system.shortcode.
It then appears automatically in the Create picker for its type (and subType),
alongside the shipped archetypes. A brand-new archetype with a fresh shortcode
needs no particular priority value — it shows up regardless of priority.
Override an existing archetype (prioritization). To replace a shipped (or
another module’s) archetype rather than add a new one, ship a document with the
same shortcode as the target and a higher docArchetype priority than
the one you are overriding:
- SoHL ships its stock archetypes at priority 0, so any positive number overrides a stock archetype.
- To override another module’s archetype, exceed its priority.
- Source-tier rule. At equal priority the source tier decides — world >
system > module (a lower tier wins). So a GM’s own world copy of an
archetype always outranks any module, and a module left at priority
0loses to the system archetype it collides with (it cannot silently clobber it). - Equal priority and tier (module vs. module) resolves by a stable but arbitrary UUID tiebreak — pick distinct priorities if the outcome matters.
Shortcode is stable identity. The shortcode is the override/dedup key: never
localize or casually rename it (same spirit as the stable lang/en.json keys).
The display name is free to vary or be localized — the picker labels options
Name (shortcode) precisely because dedup can collapse divergent names.
See Extension Points → Create-dialog archetypes for the full contract, the Foundry-free discovery helper, and the instantiation-strips / copy-preserves boundary.
Credits & attributions
Every SoHL package — the system and each module — surfaces its credits the same way: a Credits button at the top of its own tab in Game Settings, which opens a JournalEntry the package ships in its own compendium. This exists so third-party attribution reaches the people running the game, rather than sitting in a file on disk that nobody opens. If your module bundles artwork, fonts, or audio under a license that requires attribution (Game-Icons’ CC BY 3.0, most Noun Project icons), this is how you satisfy it in the medium.
There are three steps, and the system provides the hard part.
Ship a credits JournalEntry in your module’s own compendium. It is an ordinary journal — nothing about it is special to SoHL.
Declare its UUID in your
module.json, under the shared flag key:{ "id": "my-module", "flags": { "sohl": { "creditsUuid": "Compendium.my-module.journals.JournalEntry.abcdef0123456789" } } }Register the menu during
init, passing your module id:Hooks.once("init", () => { sohl.apps.foundry.registerCreditsMenu("my-module"); });Every display string may be overridden with your own
langkeys:sohl.apps.foundry.registerCreditsMenu("my-module", { name: "MYMODULE.Credits.name", label: "MYMODULE.Credits.label", hint: "MYMODULE.Credits.hint", icon: "fa-solid fa-heart", });
Register it first. Foundry renders a package’s settings menus in
game.settings.menus insertion order, ahead of its plain settings — so call
registerCreditsMenu before your module’s other registerMenu calls, or the
button will not be at the top of your tab.
It is not GM-only. The menu registers with restricted: false by default,
because credits exist to be read. Pass restricted: true only if you have a
specific reason to hide them from players.
If the manifest flag is missing, registration no-ops with a console warning rather than adding a button that silently does nothing — so a forgotten flag fails visibly.
Why a factory rather than a class you subclass: Foundry constructs a settings
menu’s type with no arguments and then calls render(true) on it, so your
UUID cannot be passed in at construction. registerCreditsMenu builds a menu app
that closes over the UUID and opens the journal instead of rendering a window of
its own. makeCreditsMenuApp and
openCreditsJournal are exported if you need the pieces
separately.
Re-skins and theming
A module can restyle the system purely through CSS by overriding the --sohl-*
custom properties — no markup changes. See
CSS Architecture & Styleguide.
Boundaries
- Don’t modify system source to achieve integration — use the seams above.
- Respect backwards compatibility and the data model. A module must not rename or reshape system data fields; if it stores its own data, namespace it under your module id (e.g. via flags).
- Keep the logic boundary in mind. The system’s game rules live in a Foundry-free logic layer; mirror that separation in your own module where it helps testability.
For the standards your contributions to the core system must meet (as opposed to a standalone module), see System Development.