Skip to content

Concepts Events And Hooks

bryanthaboi edited this page Jul 19, 2026 · 1 revision

Events and hooks

Two buses, two contracts. Events notify: past-tense names (battle.started, pokemon.caught), one payload table, listeners observe and may mutate only documented fields. Hooks intercept: noun names (battle.damage, music.select), middleware over the vanilla function, each link may rewrite arguments or results. Nothing hands mods a way to bypass engine invariants — observed, then intercepted, never bypassed.

The complete catalogs, with payloads and emit/call sites read from the engine source, are Reference: Events and Reference: Hooks.

Events

mod.events:on(name, callback, priority)    -- subscribe; returns unsubscribe()
mod.events:once(name, callback, priority)  -- retire after the first fire
  • priority (default 0): higher runs first within one event.
  • The callback receives the payload table. Payloads are shared between listeners; treat fields as read-only unless the event is documented mutable (pokemon.before_give is the canonical mutable event — rewrite gift.species, gift.level, gift.nickname).
  • Dispatch walks a snapshot of the listener list, so a listener may unsubscribe itself (or a sibling) mid-emit; once relies on this.
  • A listener that raises is logged against its mod and skipped (src/mods/Events.lua:emit); the engine path that emitted always completes.

A mod may also broadcast to other mods — only under its own prefix, so no mod can forge an engine event:

mod.events:emit("mod.my_mod_id.season_changed", { season = "winter" })

Hooks

mod.hooks:wrap(name, function(next, ...)
  -- before: inspect or rewrite the arguments
  local result = next(...)   -- call downstream (ends at vanilla)
  -- after: inspect or rewrite the result
  return result
end, priority)
  • Links form a chain sorted by priority (higher first, default 0); the innermost call is the vanilla function. With nothing wrapped, Runtime.call(name, vanilla, ...) returns vanilla(...) unchanged — that is the parity guarantee.
  • next(...) with no arguments forwards the current arguments unchanged; passing arguments rewrites them for everything downstream.
  • Return what the site expects. A hook that returns a wrong type at a UI site (ui.start_menu.items and friends) is discarded with a logged error and the vanilla value is kept.
  • Error isolation (src/mods/Hooks.lua:call): a link that throws before calling next is skipped and the chain continues; a link that throws after its next returned keeps the downstream result (its post-processing is discarded). Vanilla runs at most once per call — it has side effects — so a failure never re-walks the chain.
  • wrap returns an unsubscribe function.

Choosing between them

Prefer data over code, and the mildest mechanism that works:

  1. A registry record if the thing you want to change is content (stats, text, a song, a party). See Registries.
  2. A hook if you need to change a decision the engine makes (damage, catch rate, which song plays).
  3. An event if you only need to know something happened.

Performance

Engine call sites guard payload construction: Runtime.wants(name) / Runtime.wantsHook(name) are one table lookup, so an event nobody subscribed to and a hook nobody wrapped cost nothing on hot paths (src/mods/Runtime.lua). Subscribing to a hot event (world.stepped, battle.damage_dealt) makes the engine start paying for that payload — keep such listeners cheap.

Naming

Events are past-tense domain.subject_verbed (battle.move_used, map.entered); hooks are noun domain.operation (battle.accuracy, warp.destination). pokemon.before_give predates the rule and is grandfathered forever.

Clone this wiki locally