Audience: Developers, contributors, and anyone needing a mental model of the system.
See also: Documentation Hub, Extension Points, SohlLogic.
New to the codebase? Start here. Read this page top to bottom for the mental model, then branch out through the Where to learn more index at the end.
What SoHL is
Song of Heroic Lands (SoHL) is a Foundry VTT game system implementing HârnMaster-compatible gameplay with deep automation: actors, items, active effects, combat resolution, chat cards, UI sheets, localization, and compendium content. All code targets Foundry VTT v14+.
Directory structure
src/
├── core/ System foundations, split into logic/ (Foundry-free: system
│ registration, the Logic base, event queue, hook bridge) and
│ foundry/ (the DataModel base, calendar) plus the FoundryHelpers shim.
├── entity/ Pure, Foundry-free game-mechanics objects: modifier/, result/,
│ body/, movement/, action/, roll/, event/, expr/ (SafeExpression),
│ strikemode/, skill base.
├── document/ Foundry document classes, grouped by kind:
│ ├── actor/ actors — foundry/ holds DataModel + Sheet, logic/ holds Logic + Data
│ ├── item/ items — same foundry/ + logic/ split
│ └── effect/ · combat/ · combatant/ · token/ · scene/ the other document kinds
├── apps/ Standalone Foundry application windows (foundry/ + logic/ split).
├── utils/ Shared utilities: constants/enums, helpers, collections, logging.
└── sohl.ts Entry point: Foundry hooks and system registration.
templates/ Handlebars (.hbs) templates: actor, item, chat, dialog, effect.
tests/ Vitest suite (mirrors src/).
docs/ Developer & API documentation.
assets/ Compendium data, icons, templates.
lang/ Localization (en.json).
This map stays at the directory level on purpose. Directories are stable; the specific files inside them get added, renamed, and moved between foundry/ and logic/. For the actual classes in any folder, consult the API reference, whose navigation mirrors this layout (see next section) and is regenerated from source on every build.
How the API reference is organized
The generated API reference mirrors this directory layout. Its sidebar groups every public symbol the same way the source is grouped — Core, Documents (Actor, Item, Combat, Combatant, Chat, Effect, Scene, Token), Domain (Action, Body, Modifier, Movement, Result, StrikeMode, SkillBase), Utility (AI, Collection, Constants, Helpers), and Applications — so a folder here maps directly to a navigation group there. Start from the group that matches the area you’re working in.
Three-layer architecture
Foundry layer
Document classes (SohlActor, SohlItem, SohlActiveEffect) handle Foundry VTT integration: persistence, data preparation lifecycle, sheet rendering, and document operations. These live in src/document/*/foundry/, src/core/foundry/, src/apps/foundry/, and the one boundary shim src/core/FoundryHelpers.ts — the only module that touches Foundry globals on behalf of the logic layer.
Logic layer
Logic classes handle game rules, calculations, and actions — separated from Foundry persistence and UI. They live in src/document/*/logic/.
Foundry isolation. The logic layer is forbidden from interacting with anything Foundry-level directly. It reaches Foundry through exactly two channels:
- The
FoundryHelpersshim (src/core/FoundryHelpers.ts) — every Foundry global or API call (game.*,canvas.*,Hooks.*,Roll, dialogs, …) goes through itsfvtt-prefixed wrappers. See FoundryHelpers shim. - The Data interfaces on
logic.data— a document’s persisted shape is a*Datainterface (SohlItemData/SohlActorDataand per-type extensions) that the logic layer owns and the FoundryDataModelimplements. Logic reads a document’s persisted fields throughlogic.data(the typed shape), never by reaching intodocument.systemor Foundry internals. These contracts live insrc/document/item/logic/SohlItemBaseLogic.tsandsrc/document/actor/logic/SohlActorBaseLogic.ts(the Foundry-side base modules re-export them for compatibility).
Logic/domain code may also reference Foundry-coupled classes (SohlItem, SohlActor, SohlTokenDocument) with import type only — type imports are erased at compile time and create no runtime dependency.
Persisted *Data fields are references (a UUID, id, or shortcode); the logic layer resolves them to live objects. This “reference on the wire, live object in memory” rule spans the whole system and is the canonical serialization contract — see Runtime Contracts → the grounding rule.
This isolation is precisely what makes the logic layer unit-testable with no Foundry running: both channels are replaced with test doubles (see Testing). It is enforced two ways:
- An ESLint rule (
@typescript-eslint/no-restricted-importsineslint.config.js) forbids value imports of Foundry-coupled modules from the Foundry-free zones. - A purity smoke test (
npm run test:purity, part ofbuild:noci) imports every logic/domain module with no Foundry globals present; any module-levelfoundry.*/game.*access fails the build.
Domain layer
Pure game-mechanics objects in src/entity/ — modifiers, test results, body structure, move-base helpers, skill base computation. These have no Foundry dependency at all and are fully unit-testable.
The Foundry-free zone is larger than one directory. It spans
src/entity/, everysrc/**/logic/folder (src/core/logic/,src/document/*/logic/,src/apps/logic/), and all ofsrc/utils/— the shared utilities (helpers, collections, constants, logging) are pure and carry no Foundry dependency. Onlysrc/core/FoundryHelpers.tsand thesrc/**/foundry/folders are Foundry-coupled. The ESLint rule and purity test above police this whole zone, not justentity/.
Presentation layer
Sheet classes and Handlebars templates (.hbs) handle UI. Sheets extend SohlActorSheetBase or SohlItemSheetBase. Chat cards use templates in templates/chat/.
Sheet view-model modules
A sheet (or settings app) is Foundry code: it owns the DOM, fires hooks, mutates documents, and enriches HTML. But its _prepare*Context methods often also contain pure data-shaping — grouping, sorting, hierarchy building, label/scope resolution. That logic belongs in the Foundry-free layer, not inline in the sheet, so it can be unit-tested and is covered by the boundary guards.
The convention: a sheet/app class FooSheet/FooApp with pure view-model logic gets a foo-sheet-view.ts (apps: foo-view.ts) module of free functions in the sibling logic/ directory (e.g. actor/logic/being-sheet-view.ts, apps/logic/domain-manager-view.ts). These take accessor callbacks or minimal structural inputs — never Foundry document types — so they stay value-Foundry-free; the sheet keeps only orchestration and delegates the shaping. The canonical example is being-sheet-view.ts.
Create such a module only when there is real logic to hold — trivial field-injection (system.foo → context.foo) stays inline. Don’t add empty placeholder modules.
Three-class pattern
Every actor and item type is split into three classes across two directories:
Logic class (e.g.,
SkillLogicinitem/logic/SkillLogic.ts) — business logic, calculations, actions. Participates in the phase-batched lifecycle:initialize()→evaluate()→finalize(). Also contains the Data interface.DataModel class (e.g.,
SkillDataModelinitem/foundry/SkillDataModel.ts) — extends FoundryTypeDataModel, defines the persisted schema viadefineSchema().Sheet class (e.g.,
SkillSheetinitem/foundry/SkillSheet.ts) — UI presentation, extends the appropriate sheet base class.
The logic/ directory contains code that can run and be tested without Foundry VTT. The foundry/ directory contains code that depends on the Foundry runtime.
Custom schema fields
A defineSchema() is built from Foundry’s own foundry.data.fields.* types. SoHL adds two of its own, both in src/core/foundry/ and both StringField subclasses — reach for them instead of a bare StringField when they apply:
| Field | Use it for | Notes |
|---|---|---|
| SafeExpressionField | A GM-authored formula or predicate (a Skill Base, an affliction’s outcome) | Marks the field as holding a SafeExpression source, which is what gives it the code editor on the sheet rather than a plain text input. Declare its scope — see Expressions and Scripts. |
| URLField | A string that must be a well-formed http(s) URL | Validates on assignment, so a malformed URL is rejected at the schema boundary. |
Both store an ordinary string, so neither changes the persisted type. They add behavior: validation, and (for SafeExpressionField) the editing affordance the sheet keys off.
Switching an existing field is not automatically a no-op, though. SafeExpressionField defaults to nullable: true, blank: false, so Foundry cleans a legacy "" to null — the right shape for “unset” (see null vs. undefined), but a change in the values downstream code sees. Check the readers before converting a populated field.
Accessing a document’s data and logic
At runtime, document.system is the DataModel instance and document.logic (≡ document.system.logic) is the Logic. The DataModel implements the type’s *Data interface, so the persisted fields are the same object whichever way you reach them.
For typed, documented access, prefer document.logic.data. SohlLogic.data returns the *Data interface — e.g. skillItem.logic.data is typed SkillData — so editors autocomplete the fields and the API reference links straight to the shape. document.system holds the identical object but is typed as the Foundry-internal DataModel class (excluded from the API docs).
actor.logic.data.foo— fully typed via the public*Datainterface (recommended).actor.system.foo— the same value, but typed as the internal DataModel.
So to discover what’s available on a document, read its *Data interface (the shape of system / logic.data) and its Logic class (computed properties, intrinsic actions). The DataModel and Sheet classes are Foundry binding and are intentionally absent from the API docs.
A being’s physical baseline lives on a body sub-object; movement is universal
A being’s physical baseline — anatomy, body weight, reach, body scale, and personal
fatigue — lives on the Being actor itself, under system.body, and is derived by
a Being-owned BodyLogic exposed as being.body.
There is no separate physical-body item: a being with an empty body structure
(being.body.structure.parts.length === 0) is incorporeal — a spirit, with no body
structure, weight, or reach. That is a supported state, not an error; check
being.body.isIncorporeal. Reach body-owned state through being.body:
- On BeingLogic directly:
health,healingBase(aValueModifierwhose base is the average of the being’s Endurance and Will scores — rounded up when END > WIL, else down — and which trait/treatment deltas layer onto; multiplied by a Healing Rate it is the target of nearly every recovery test),shockState(an ascending severity level None…Dead derived from the Stunned/Incapacitated/Unconscious/Dead status effects — not a stored field; set it throughsetShockState/advanceShockState, never by toggling statuses directly),fatiguePenalty(aValueModifiersumming the Fatigue Levels of everyfatigue-subtype trauma — windedness/weariness/weakness recorded as separate instances — which penalizes tests and Move rate),pull, andcarriedWeight(the ground-up total of carried gear). - On
being.body(a BodyLogic):structure,weight,reach,bodyScale,injuryTable, andisIncorporeal.
Movement is not a being-only concept. It is a universal actor capability on the
base SohlActorBaseLogic (see also
src/document/actor/logic/movement.ts), so every actor — Being, Vehicle, Cohort,
Structure — inherits currentMoveMedium + movementProfiles and the derived
feetPerRound / leaguesPerWatch / moveProfile (the resolved profile for the actor’s
currentMoveMedium), plus the makeDefaultMedium intrinsic action. The default medium
is MOVEMENT_MEDIUM.NONE — a non-mover, represented by the NONE_MOVE_PROFILE constant
in movement.ts, which is never authored per-actor. BeingLogic additionally derives
movement’s strengthModifier and encumbrance from its str attribute and
carriedWeight.
const being = actor.logic; // BeingLogic
being.carriedWeight.effective; // carried gear weight — on the being
being.feetPerRound.effective; // tactical move — universal actor state
being.body.structure; // anatomy — on the being's body sub-object
being.body.isIncorporeal; // true when the body structure is empty
At the data layer, the anatomy/weight/reach/scale schema is the body SchemaField on
BeingDataModel (see Body Structure → Where the data lives), while the movement schema is on the base actor DataModel.
Document types
SoHL defines several actor and item types, each following the three-class pattern above. The exact set drifts as types are added or renamed, so this page does not enumerate them — consult the authoritative sources instead:
ACTOR_KINDandITEM_KINDinsrc/utils/constants.ts— the canonical set of type codes.- Type Catalog — what each type is and how they interact.
- The API reference — every type’s classes, under Documents → Actor and Documents → Item.
In short: actors model the entities in the world — individual beings and creatures, groups, structures, vehicles, and item-container assemblies. Items model the capabilities and possessions an actor carries — skills, gear, afflictions and injuries, traumas, and mystical abilities.
Phase-batched lifecycle
Foundry VTT processes each embedded item fully before moving to the next, so sibling items cannot depend on each other. SoHL overrides prepareEmbeddedData() to run three phase-batched passes across all items with barriers between them:
- initialize — set up base state from persisted data. Cannot read sibling items.
- evaluate — compute derived values using sibling items’ initialized state.
- finalize — resolve cross-item dependencies requiring evaluated state.
See SohlLogic for the full rationale and rules, and Lifecycle Hooks for augmenting a phase from a module.
Domain objects
Game-mechanics value objects in src/entity/ are rebuilt from persisted data each preparation cycle. They may be mutated during the lifecycle (e.g., active effects adding modifiers), but mutations are not persisted.
| Directory | Purpose |
|---|---|
domain/modifier/ | Auditable tracked values built from a base plus ordered deltas. |
domain/result/ | Test and combat resolution outcomes. |
domain/body/ | Anatomical structure with weighted hit-location selection. |
domain/movement/ | Per-medium base-move lookup. |
domain/action/ | Executable action definitions (context-menu entries, chat buttons). |
domain/ (top level) | Skill-base formula computation. |
For the classes in each area, see Domain in the API reference, and Modifier Model, Combat Resolution Pipeline, and Body Structure.
Active Effects
SohlActiveEffect extends Foundry’s ActiveEffect with an extended targeting model:
targetType:this(self),actor(owning actor), or<itemType>(other items on actor)targetName: regex pattern matched against item shortcodes
See Effects Integration.
Extension mechanisms
SoHL provides two levels of extension for customizing behavior without modifying core source:
Modules via lifecycle hooks — Foundry modules listen for hooks emitted at each lifecycle phase to augment behavior broadly across items or actors. See Lifecycle Hooks.
Actions — document-attached executable logic surfaced on context menus, for per-item behavior. See Macros and Actions.
FoundryHelpers shim
src/core/FoundryHelpers.ts wraps all Foundry VTT globals (game, canvas, Hooks, Roll, etc.) behind a stable API. During testing, vitest swaps it for a mock. Logic classes must import from FoundryHelpers, never from Foundry directly.
Shim exports use the fvtt prefix (e.g., fvttGetSetting, fvttCallHook). Functions that don’t wrap globals (e.g., inputDialog, getContextLogic) use plain names.
For UI notifications, use sohl.log.uiWarn / sohl.log.uiError (SohlLogger), not the shim.
Build system
npm run build runs the full pipeline (type-check → test → bundle with Vite) into build/stage/, which mirrors the installed Foundry system directory — Foundry could load it as-is. For the script catalog, the pipeline stages, the build/ layout, and how system.json is assembled, see Build, Deployment, and Release.
Actor state sovereignty
An actor — together with its token and combatant — is the sole authority over its own state. An actor’s logic or client may mutate only itself; it must never write directly to another actor (or another actor’s token or combatant).
Any effect one actor would impose on another is mediated by a chat-message handshake:
- The source resolves its own side — rolls the attack, the spell-success test, etc. — mutating only itself.
- On success it posts a chat card with a button addressed to the target’s owner, whose label states unambiguously what is being acknowledged or applied.
- The target’s owning client clicks it, runs any required test first (e.g. a resistance roll), and applies the change to itself.
Example: a wizard casts Sleep on a victim. The wizard rolls to land the spell; on success the card shows the victim’s owner a button (“Acknowledge you fall asleep”, possibly gated behind a resistance test). The victim’s client marks the victim asleep — the wizard never touches the victim’s state.
Why: Foundry permissions — a client can only reliably update documents it owns — and player accountability: each player (the GM owns all actors) stays in control of, and every cross-actor consequence stays visible in chat for, their own character.
This already underlies automated combat: defense buttons dispatch to the defender’s client, and the “Calculate Injury” button resolves on the target’s client. To implement a new cross-actor mechanic, follow Extension Points → Cross-actor effects.
Architectural rules
- Logic layer stays Foundry-free. All Foundry API calls go through
FoundryHelpers.ts. - Extension over rewrites. Use hooks and actions, not source modifications.
- Backwards compatibility. Never rename data fields without a migration strategy.
- No global search-and-replace. Cross-cutting changes must be scoped and validated.
- Stable localization keys. Never rename keys in
lang/en.json— add new ones. - Small, focused changes. One feature or fix per PR.
- Complete implementations. No placeholder stubs.
- Actor state sovereignty. An actor mutates only itself; cross-actor effects go through a target-addressed chat acknowledge button (see above).
Where to learn more
| Topic | Document |
|---|---|
| Lifecycle phases | SohlLogic |
| Extending SoHL | Extension Points |
| Lifecycle hooks | Lifecycle Hooks |
| Actions | Macros and Actions |
| Expressions/scripts | Expressions and Scripts |
| Security & guardrails | Security Model |
| Combat resolution | Combat Resolution Pipeline |
| Modifier system | Modifier Model |
| Body anatomy | Body Structure |
| Active effects | Effects Integration |
| CSS/styling | CSS Architecture |
| Testing | Testing Guide |
| Type catalog | Type Catalog |
| Runtime contracts | Runtime Contracts |