5 Concepts Events And Hooks
bryanthaboi edited this page 2026-10-20 17:01:40 -04:00

Events and hooks

Two buses, two contracts. A callback is a function the game calls later. An event gives its callback a payload table that describes something that already happened; a hook lets its callback participate while the engine is deciding a result.

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.

One vocabulary, three generations

Event and hook names are shared by Gen 1, Gen 2 and Gen 3: nothing is prefixed or renamed per generation. A name is raised only where the game has a call site. Most gameplay names (battle.damage, map.entered, world.stepped) fire on all three; the Pokegear, radio, apricorn and Gold/Silver boot-cinema names exist on Gold, Silver and Crystal only; shininess, gender, held items, breeding, happiness, Pokerus and roamer names fire on Gen 2 and Gen 3; link and trade names follow the link play each game has. Subscribing to a name the booted game never raises is legal and simply never fires, so one source file loads everywhere. The per-generation tables are in the reference pages, and an api: 3 mod can ask at runtime with mod:supports("hooks", name) or mod:supports("events", name) (see Portable API 3).

Events

Code placement: subscribe in your mod entry file. Status: this is a complete read-only listener; it records information but does not alter play.

mod.events:on("map.entered", function(ev)
  mod.log:info("Entered %s", ev.mapId)
end)

Here ev is the event payload: a Lua table whose named fields are listed in Reference: Events. Read a payload unless that catalog marks the event mutable.

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

For an ordinary hook that returns a value, the usual path is:

engine asks for a result -> your wrapper -> next(...) -> vanilla code
                                  <- result <-
                            return result to the engine

Call next and preserve its result unless the hook's own contract says you are changing it. This is a useful default, not a universal rule: trainer.before_battle deliberately supplies a continue function instead of an ordinary result path. Read the named hook's entry in Reference: Hooks before writing a wrapper.

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, intro.oak_speech.build, 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.

One hook hands its wrapper a continuation instead of expecting a value back: trainer.before_battle receives a one-shot continue so a mod can defer a trainer engagement across its own UI. Its contract lives on the hook reference. A few other wrappers are not "call next, return its result" either: input.key, input.gamepad, input.step and render.compose have documented rules for when skipping next is allowed, so read the entry first.

API 3 callbacks

For an api: 3 mod, mod.events and mod.hooks keep the same on, once, emit and wrap forms, priorities and unsubscribe closures. The difference is at the boundary: record-valued payloads (species, moves, items, parties, mons) are converted to the shared record shape on the way in and back on the way out, including the mutable pokemon.before_give payload, while game, battle and context handles stay native. Only catalogued names are accepted; an unknown name raises. A mod may subscribe to another mod's events but emits only under its own mod.<id>.* prefix, and engine events cannot be forged. The same rule applies to api 1 and 2 mods.

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.