Skip to content

Modding Guide

How mods extend an Ana game: directory layout, the mod.meta manifest, load order, and the injection patterns (@into / @wrap / @on) that let a mod add content without editing a single base-game file.

Reference: $mods namespace & mod queries. Guide: Hooks & Events teaches @hook/@into/@wrap/@on in depth; Boot, Saves & Mods covers the boot sequence and save/version model.

Overview

Ana mods are collections of .ana passage files (plus optional assets and JavaScript) that load after the base game's passages. Because Ana's hook and wrap system is injection-based, a mod adds, modifies, and extends content without editing any base-game file, and mods use the exact same API the engine's own systems do, with no privileged internal access a mod can't replicate.

What a mod can do:

  • Inject links and content into any @hook the base game exposes
  • Wrap any passage to run behavior before/after (or instead of) it
  • Register new NPCs, items, containers, and events
  • Add new scenes and quests
  • Handle engine events (dayAdvance, gameLoad, …)
  • Ship a macros.js to register named JavaScript-backed macros (deck logic, canvas widgets, minigames); see JavaScript macros

What a mod cannot do:

  • Override a base-game passage (passage names must be unique)
  • Change the declared type of a $ variable
  • Override a built-in macro (a JS-macro registration that collides with a built-in is refused)

Directory structure

Place mods under mods/ in the project root:

mods/
  my-mod/
    mod.meta              ← required: metadata (name, version, requires, conflicts)
    macros.js             ← optional: JavaScript-backed macros
    passages/
      MyMod_Content.ana   ← any .ana files; subdirectories allowed
      MyMod_Init.ana
    assets/               ← optional: images, audio (reference via relative path)

The engine discovers every mod.meta under mods/, resolves load order from each mod's requires, then loads all .ana files from mods in dependency order.

The mod.meta manifest

mod.meta is a plain key: value text file, one entry per line:

name: My Mod
version: 1.0.0
author: Your Name
requires: Base Game
conflicts: Incompatible Mod
FieldRequiredDescription
nameYesUnique mod name (used by (mod:) and the $mods namespace)
versionYesSemver string (e.g. 1.0.0)
authorNoAuthor name
requiresNoMod names this mod depends on (a required mod always loads first)
conflictsNoMod names this mod cannot coexist with

There is no load_order field. Mod authors don't set a priority; load order is derived from dependencies (below), so the system stays predictable as more mods are added.

Load order & conflicts

Load order is derived from requires via a dependency sort: a mod that requires another always loads after it, and mods with no dependency relationship load alphabetically by name for stability. Removing the manual load_order field means no two mods can both insist on "loading last."

  • requires: every listed mod must be present, or boot fails with an error naming the missing dependency. A required mod is guaranteed to load before the mod that needs it.
  • conflicts: if a conflicting mod is also loaded, boot fails with an error. Use conflicts when your mod would produce undefined behavior alongside another.
  • Circular dependencies (A requires B, B requires A) are detected and reported as an error.

Hook insertion order: base-game passages load first. Mod injections (via @into) are added after base content, in load order, and append by default; a mod can insert before existing hook content with @into(hook, prepend) (below).

Injecting content: @into

@into(hookName) causes a passage to inject its content wherever the base game's @hook(hookName) marker appears.

The base game defines the hook:

ana
:: BarScene

@zone(options)
@hook(bar_options)
(link: "Leave")[(goto: MainStreet)]

A mod injects into it, and no base-game file is touched:

ana
:: MyMod_BarOption @into(bar_options)

(link: "Talk to the stranger")[(goto: MyMod_StrangerConvo)]

The injected content is placed at the @hook position, before the content that follows it in the base passage. Multiple mods can inject into the same hook; each adds its content in load order.

Prepend vs. append. By default injections append (added after any earlier injections at the hook). A second argument controls placement:

ana
:: MyMod_FirstOption  @into(bar_options, prepend)   // before other injections
:: MyMod_LastOption   @into(bar_options, append)    // explicit default

All prepend injections render before all append injections; within each group, load order is preserved. @into is deliberately additive and cannot wipe base content; to replace it entirely, use @wrap (below) with no @wrapped().

Wrapping passages: @wrap

@wrap(PassageName) wraps a named passage, running before and/or after it. @wrapped() marks where the original passage body runs:

ana
:: MyMod_WrapBarScene @wrap(BarScene)

// Runs before BarScene
(if: $mymod.events contains "stranger_arrived")[
    @zone(sidebar_npc)
    A hooded stranger watches from the corner.
]

@wrapped()

// Runs after BarScene

When multiple mods wrap the same passage, the last-loaded wrapper is outermost. Omitting @wrapped() replaces the original passage entirely. This is powerful, and the only way to fully replace base content, so use it deliberately.

Event handlers: @on

Handle engine and game events from a mod without modifying any base-game passage:

ana
:: MyMod_OnDayAdvance @on(dayAdvance)

(if: $mymod.events contains "stranger_arrived")[
    (add: $mymod.events, "days_since_arrival")
]

Common events: dayAdvance (time crosses midnight), periodChange (time period changes in cycle mode), gameLoad (a save is loaded), gameStart (a new game begins). Many system events also expose context as _temp vars for the handler's duration; see Events & Status for the full list.

Custom events

A mod can trigger its own events, and any other mod (or the base game) can handle them:

ana
// Mod A triggers an event
(trigger: "mymod.stranger_appeared")
ana
// Mod B (or the base game) handles it
:: Handler @on(mymod.stranger_appeared)

(notify: "You sense something has changed...")

Namespace custom event names (modname.eventname) to avoid collisions.

Mod-aware content: passage-exists & $mods

Use (passage-exists:) so base-game content can reference mod passages that may not be loaded, degrading gracefully when the mod is absent:

ana
(if: (passage-exists: "MyMod_SpecialScene"))[
    (link: "Visit the special location")[(goto: MyMod_SpecialScene)]
]

For content keyed on the mod itself rather than a specific passage, use (mod:) and (mod-list:):

ana
(if: (mod: "Extra Outfits"))[
    You see an extra rack of clothes against the wall.
]

(each: _m in (mod-list:))[Loaded mod: _m]

The engine fills the $mods namespace at boot with each mod's author, version, requires, conflicts, passages, wraps, and into data.

Mod init passages

Mods that define NPCs, items, containers, or other game objects use an @system(init) passage. It runs alongside the base game's GameInit on new game / (game-new:), before the first (start:) navigation:

ana
:: MyMod_Init @system(init)

// Mod state
(declare: $mymod.events, [])

// New NPC
(npc-define: "stranger", name, "The Stranger", attitude, 0)

// New items
(item-define: "strange_coin", stackable, type, "currency", value, 100)

// New container
(container-define: "strangers_satchel")

Because init passages re-run on every load (before saved values are deserialized over them), keep them to declarations, definitions, and config; put one-time side effects in a (start:) passage or an @on(gameStart) handler instead. See Boot, Saves & Mods for the full rule.

JavaScript macros

For behavior the passage language can't express (a card deck's shuffle, a canvas widget, a minigame), a mod can drop a macros.js next to its mod.meta. It default-exports a function that receives the engine API and registers named macros:

js
// mods/my-mod/macros.js
export default function register(engine) {
  engine.macros.register('mymod-dice', (args) => {
    const sides = Number(args[0]) || 6;
    return Math.floor(Math.random() * sides) + 1;
  });
}
ana
(if: (mymod-dice: 20) >= 15)[A critical hit!]

These load once at boot (after author scripts, in mod load order) and persist across new-game/load, like built-ins. The full API (the (args, ctx) surface, content output, mount()/onCleanup() for animations, and the collision rules) is documented in Extending with JavaScript.

Testing your mod

  1. Place your mod under mods/your-mod-name/ with a mod.meta file.
  2. Run npm run dev: the engine loads mods automatically at boot.
  3. Check the console: mod-loading logs appear: [Ana] Loaded mod "your-mod-name" (N passage file(s)).
  4. Use the dev debugger (backtick key) to verify your mod loaded:
    • Passages panel: search your passage names to confirm they're registered.
    • Hooks panel: navigate to the base passage you inject into and verify your passage appears under "Hook Injections."
    • Trace panel: walk through your mod's scenes to verify the flow.
  5. Check for conflicts: if a hook injection doesn't appear, look for mod-load warnings in the console.