Writing Modules for SoHL

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 shared offerSchedule(context, doc, actionName, interval) helper (a context.scope.schedule answer, else a private default-No dialog); a module action can call sohl.schedule conditionally 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:

  1. 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.

  2. 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}, plus sceneDarknessChange for 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: nextFireTime is undefined; use actor.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.docArchetype set 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 0 loses 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.

  1. Ship a credits JournalEntry in your module’s own compendium. It is an ordinary journal — nothing about it is special to SoHL.

  2. Declare its UUID in your module.json, under the shared flag key:

    {
      "id": "my-module",
      "flags": {
        "sohl": {
          "creditsUuid": "Compendium.my-module.journals.JournalEntry.abcdef0123456789"
        }
      }
    }
    
  3. 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 lang keys:

    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.