5
Reference Hooks
bryanthaboi edited this page 2026-08-11 11:18:37 -04:00

Hook reference

Every hook the engine calls, with the arguments a wrapper receives after next, what it must return, and the call site. Read from the Runtime.call sites in src/. Wrap with mod.hooks:wrap(name, function(next, ...) ... end, priority); see Concepts: Events and Hooks for chain order and error isolation.

A wrapper's contract: call next(...) at most once, return the same shape the vanilla function returns. next() with no arguments forwards the current arguments unchanged.

Every hook on this page fires on Gold as well as on Red, Blue and Yellow. Of the 54 hook names the engine raises, 43 have an explicit Gen 2 call site beside the Gen 1 one, 6 more are raised from code both generations share (music.select, music.volume, pokemon.icon, screen.render_visible, zoom.range, link.fingerprint), and 5 are Gold-only because the mechanic is. See Gen 2 (Gold) hooks at the foot of this page for those five and for the handful of payloads that gain a key on Gold. A mod that subscribes to a hook needs no generation branch and no engine_internals permission; see Preparing Your Mod for Gen 2 for the parts of the engine where that stops being true.

Battle

Hook Wrapper receives (after next) Returns Call site
battle.accuracy ctx = { battle, ruleset, move, user, target, rng } true on hit src/battle/BattleState.lua (accuracyRoll)
battle.damage ctx = { battle, ruleset, user, target, move, opts, rng } damage, { crit, typeMult } src/battle/BattleState.lua (computeDamage)
battle.crit ctx = { ruleset, attacker, moveId, rng, highCrit } true on crit src/battle/Damage.lua
battle.turn_order playerBattler, playerMove, enemyBattler, enemyMove, ctx = { rng, invertTie? } true when the player moves first src/battle/BattleState.lua; also src/link/LinkBattle.lua (must stay deterministic across peers)
battle.enemy_action battle the enemy action src/battle/BattleState.lua (enemyAction — the whole AI choke point)
battle.run ctx = { battle, pSpd, eSpd, attempts, rng } true on escape src/battle/BattleState.lua (runRoll)
catch.rate ball, mon, def, opts = { rng, rateOverride, battle } caught, shakes src/battle/BattleState.lua (catchAttempt)
trainer.party trainerClass, partyIndex, party the party list ({ { species, level }, ... }) src/battle/BattleState.lua (trainer battle setup)
exp.gain ctx = { defeatedDef, level, isTrainer, participants, traded, mon } the exp number src/battle/Experience.lua
pokemon.sprite path, ctx = { species, side = "front"|"back", kind, mon?, trueColor, data } image path; may set ctx.trueColor src/pokemon/Sprites.lua (battle + menus)
pokemon.icon path, ctx = { species, mon, name?, data, kind = "icon" } party-icon image path src/pokemon/Sprites.lua / PartyMenu
player.sprite path, ctx = { side = "back"|"front", kind, demo, battle?, trueColor, data } image path; may set ctx.trueColor src/pokemon/Sprites.lua (playerPath) — battle, intro, trainer card, Hall of Fame

pokemon.sprite / pokemon.icon stay live after content freeze — the way to swap skins mid-session without patching registries. Persist the choice on the mon (a bag field, mod.save, etc.) and read it from ctx.mon:

mod.hooks:wrap("pokemon.sprite", function(next, path, ctx)
  path = next(path, ctx)
  local skin = ctx.mon and ctx.mon.skin
  if skin then
    ctx.trueColor = true
    return mod.assets:path("skins/" .. ctx.species .. "_" .. skin .. ".png")
  end
  return path
end)

ctx.kind is "battle", "summary", "dex", "evolution", "hof", "trade", "title", "oak", or "credits". Battle pics are resolved when the battler is built; reopen the summary / party menu (or rebuild the battler) to pick up a mid-fight change.

player.sprite is the same seam for the player's own trainer art: the battle back pic (RedPicBack), the catch-tutorial old man (OldManPicBack), and the front pic the intro, trainer card and Hall of Fame share (RedPicFront). The vanilla paths come from field.playerPics; the hook runs on top of whatever that resolved to, so the pic can follow save state the registry cannot see:

mod.hooks:wrap("player.sprite", function(next, path, ctx)
  path = next(path, ctx)
  if ctx.demo then return path end          -- leave the old man alone
  local outfit = mod.save:get("outfit")
  if not outfit then return path end
  return mod.assets:path(("art/%s_%s.png"):format(outfit, ctx.side))
end)

ctx.kind is "battle", "intro", "trainer_card" or "hof". ctx.side is "back" (battle only) or "front"; ctx.demo is true for the catch tutorial's old man, and ctx.battle is the live battle when ctx.kind == "battle". The path you return is loaded through the same pipeline as the vanilla one, so a replacement back pic keeps the SGB recolor, the fade-to-white on transitions, the transparent-padding measurement that keeps feet flush on the text-box top, and battle_sprite_scales lookups (register your path to rescale it). The back pic is resolved once, in BattleState:enter — a change made mid-battle shows on the next one.

Example — a global difficulty scaler:

mod.hooks:wrap("battle.damage", function(next, ctx)
  local dmg, info = next(ctx)
  if not ctx.user.isPlayer then
    dmg = math.floor(dmg * 1.25)
  end
  return dmg, info
end)

Progression and encounters

Hook Wrapper receives Returns Call site
evolution.check game, mon, evo, trigger true when the evolution fires src/pokemon/Evolution.lua (pendingFor)
encounter.roll encDef, ctx = { mapId, terrain, rng } an encounter ({ species, level }) or nil src/world/OverworldController.lua (rollEncounter)
encounter.species enc, ctx the (possibly rewritten) encounter src/world/OverworldController.lua (after a non-nil roll, before repel filtering)
encounter.fishing rod, mapId, candidates an encounter or nil src/world/OverworldController.lua (goFishing)

World

Hook Wrapper receives Returns Call site
movement.collision allowed, ctx = { map, mover, dir, fromX, fromY, toX, toY, reason } boolean; may rewrite ctx.reason src/world/Collision.lua (canMove)
movement.speed frames, ctx = { onBike, surfing, player, input, save } step duration in frames (≥1) src/world/Player.lua (tryMove — running shoes, dash)
warp.destination destMap, x, y, ctx = { warp, lastMap, data } destMap, x, y src/world/Warp.lua (destination)
map.palette name, map, ctx = { tod } a palette name src/world/OverworldController.lua
world.tod tod, ctx = { map, mapId, x, y, steps } period string ("DAY", "NIGHT", …) src/world/OverworldController.lua (timeOfDay)
zoom.range lo, hi, S lo, hi offset bounds (vanilla 1-S .. S) src/render/Zoom.lua (offsetRange)

Example — hold B to run (bike speed while on foot):

mod.hooks:wrap("movement.speed", function(next, frames, ctx)
  frames = next(frames, ctx)
  if not ctx.onBike and ctx.input and ctx.input:isDown("b") then
    return math.max(1, math.floor(frames / 2))
  end
  return frames
end)

Example — allow one extra survey-out step beyond vanilla:

mod.hooks:wrap("zoom.range", function(next, lo, hi, S)
  lo, hi = next(lo, hi, S)
  return lo - 1, hi
end)

Presentation and audio

Hook Wrapper receives Returns Call site
music.select song, ctx = { reason, mapId, mapSong, onBike, surfing, kind, battleKind, trainerId } a song id src/core/Music.lua (selectSong — every song choice passes through)
music.volume vol, ctx = { song, mapSong, mapId, x, y, tod, onBike, surfing, fading, optionScale } absolute source volume (0..) src/core/Music.lua (applyVolume — also re-applied every frame while wrapped)
transition.style ctx = { trainer, stronger, dungeon, game } a transitions registry id src/render/BattleTransition.lua (an unknown style falls back to vanilla)
render.zones game, zones the zones list src/core/Game.lua (palette-zone pass before the blit)
render.letterbox ctx = { ww, wh, pw, ph, ox, oy, vpw, vph, scale, dpiX, dpiY, worldActive } nothing (draw-only) src/render/Renderer.lua (endFrame — after the letterbox clear, before the game blit)
battle.overlay battle nothing (draw-only) src/battle/BattleState.lua (draw — after HUDs/flash)

Example — fade town music as you approach the edge:

mod.hooks:wrap("music.volume", function(next, vol, ctx)
  vol = next(vol, ctx)
  if not (ctx.x and ctx.mapId) then return vol end
  -- toy: quieter when standing near the north edge
  if ctx.y and ctx.y <= 2 then return vol * 0.4 end
  return vol
end)

Example — draw an SGB-style border in the letterbox bars:

local border
mod.events:on("game.ready", function()
  border = love.graphics.newImage(mod.assets:path("assets/sgb_border.png"))
end)
mod.hooks:wrap("render.letterbox", function(next, ctx)
  next(ctx)
  if border then
    love.graphics.setColor(1, 1, 1, 1)
    love.graphics.draw(border, 0, 0, 0, ctx.ww / border:getWidth(),
                       ctx.wh / border:getHeight())
  end
end)

Example — shiny sparkle over the wild battler:

local Stats = require("src.pokemon.Stats")  -- supported require
mod.hooks:wrap("battle.overlay", function(next, battle)
  next(battle)
  local mon = battle.enemy and battle.enemy.mon
  if mon and Stats.isShiny(mon.dvs) then
    -- draw your sparkle at the enemy HUD / pic anchor
  end
end)
Hook Wrapper receives Returns Call site
script.command ctx, name, args the command's jump result (nil, a label, "end") src/script/ScriptRunner.lua (every script row)
save.new_game save the save table src/core/SaveData.lua (New Game skeleton)
ui.start_menu.items game, items the items list src/ui/StartMenu.lua
ui.party.submenu game, items, mon, ctx = { battle, overworld } the items list src/ui/PartyMenu.lua
ui.options.rows game, rows the rows list src/ui/OptionsMenu.lua
ui.pc.items game, items the items list src/world/OverworldController.lua (the PC menu)
ui.list_menu opts, ctx = { game, title, kind, itemCount } opts table (wrap, pageJump, keyRepeat, repeatDelay, repeatRate) src/ui/ListMenu.lua (new — bag uses kind = "bag")
ui.naming.grid grid, ctx = { lower, title, maxLen, game } letter-grid rows (keep an "ED" cell and a case-switch row) src/ui/NamingScreen.lua
intro.oak_speech.build steps, speech the steps list src/ui/OakSpeech.lua (buildSteps)
link.fingerprint data, mods the digest string src/link/Fingerprint.lua (compute)

The four ui.* list hooks and intro.oak_speech.build type-check the result: return anything but a table and the vanilla list is kept with a logged error. Insert with the mod.ui helpers rather than raw table.insert when position matters:

mod.hooks:wrap("ui.start_menu.items", function(next, game, items)
  mod.ui.insertBefore(items, "QUIT", {
    label = "QUESTS",
    onSelect = function() mod.ui.push(game, "QuestLog") end,
  })
  return next(game, items)
end)

Example — bag wrap + hold-to-scroll + page jump:

mod.hooks:wrap("ui.list_menu", function(next, opts, ctx)
  opts = next(opts, ctx) or opts
  if ctx.kind == "bag" then
    opts.wrap, opts.pageJump, opts.keyRepeat = true, true, true
  end
  return opts
end)

Example — add digits to the naming grid:

mod.hooks:wrap("ui.naming.grid", function(next, grid, ctx)
  grid = next(grid, ctx)
  local out = {}
  for i, row in ipairs(grid) do out[i] = row end
  out[4] = { "0", "1", "2", "3", "4", "5", "6", "7", "8" }
  return out
end)

Entries are { label, onSelect }; anchors are stable labels ("POKéDEX", "OPTION", "QUIT", ...), and a missing anchor appends.

Oak speech steps

intro.oak_speech.build reshapes the NEW GAME naming sequence without replacing the whole screen. Call next(steps, speech) first, then insert against stable step ids with mod.ui.insertStepBefore / insertStepAfter / removeStep:

Vanilla id Kind What it does
oak_welcome say Oak fades in + welcome text
demo_mon demo flipped demo species wipe + cry + text
world_spiel say pets / fights / profession
ask_player_name say "First, what is your name?" over the player pic
name_player name naming screen → save.player.name
ask_rival_name say rival intro over the rival pic
name_rival name naming screen → save.player.rival
legend say "your very own POKéMON legend..."
shrink shrink shrink-away into the overworld sprite

Step kinds a mod may add: say, demo, name, choice, yesno, pic, shrink, fn. Pics accept "oak" / "rival" / "player", a species id, or { type = "trainer"|"pokemon"|"player"|"image"|"sprite", ... }. A saveKey on choice / yesno / name is mirrored onto speech.answers and fired through intro.oak_speech.answered.

mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
  steps = next(steps, speech)
  mod.ui.insertStepAfter(steps, "oak_welcome", {
    id = "my_quiz",
    kind = "choice",
    pic = "oak",
    saveKey = "starter_bias",
    text = "Pick a type.",
    choices = { "FIRE", "WATER", "GRASS" },
  })
  return steps
end)

mod.events:on("intro.oak_speech.answered", function(ev)
  if ev.saveKey then mod.save:set(ev.saveKey, ev.value) end
end)

Worked example: mods/examples/example_silly_oak. To replace the whole speech instead, point field.boot.screens.newGame at your own screen (Tutorial 12).

Gen 2 (Gold) hooks

Five hooks exist only on Gold, because the mechanic behind each one does. They are ordinary hooks: same mod.hooks:wrap call, same next contract. A wrapper registered on a Red boot simply never fires.

Hook Wrapper receives (after next) Returns Call site
shiny.roll ctx = { dvs, species, def, level } true when the mon is shiny src/battle/gen2/Mon.lua
gender.roll ctx = { def, dvs, ratio, species, level } "male" / "female" / "genderless" src/battle/gen2/Mon.lua
held_item.trigger ctx = { battle, mon, item, def, effect, parameter, trigger } effect, parameter src/battle/gen2/Battle.lua
breeding.compatibility ctx = { data, mon1, mon2, dayCare } 0-255 compatibility src/core/gen2/Breeding.lua
phone.contact_list save, list the contact list src/core/gen2/Phone.lua

Each one is clamped after the wrapper returns, so a wrapper cannot put the game into a state the cart could not reach:

  • shiny.roll is coerced to a boolean, and gender.roll falls back to the vanilla roll if the wrapper returns anything that is not one of the three genders.
  • held_item.trigger must return a string effect or the item does nothing; parameter falls back to the vanilla value when it is not a number.
  • breeding.compatibility is floored and clamped to 0-255.
  • phone.contact_list must return a table of exactly Phone.CONTACT_LIST_SIZE entries, and every slot is checked against the known contacts, or the vanilla list is used instead.

trigger on held_item.trigger names the moment the item is being consulted ("check" is the default). dayCare on breeding.compatibility is true when the question comes from the Day-Care pair rather than a speculative check.

Payload differences on Gold

Four shared hooks carry an extra key on Gold. In each case the Gen 1 keys are unchanged and the Gold key is added beside them, so a wrapper written for Gen 1 keeps working and a Gold-only refinement is a field test rather than a second subscription.

Hook What Gold adds
encounter.species ctx.daytime, ctx.environment, ctx.kind ("wild" / "contest" / "script" / "sweet_scent"), ctx.tables, ctx.data
pokemon.sprite ctx.letter (Unown) and ctx.shiny
battle.low_health_alarm ctx.data, because Gold's battle screen has no .data field
evolution.check data in argument 1 where Gen 1 passes game; arguments 2-4 (mon, row, trigger) are unchanged

battle.low_health_alarm is the one worth care: a Gen 1 wrapper that reached through ctx.battle.data instead of calling next gets nil on Gold. Call next and read ctx.data.

Hooks whose Gold screen is a different screen

Three ui.* hooks keep their name across both generations but are raised over a different menu, because the menu itself differs:

  • ui.pc.items is the WHICH-PC list on Gen 1 and Bill's PC's own rows on Gold.
  • ui.start_menu.items and ui.options.rows are raised over Gold's own src/ui/gen2/StartMenu.lua and OptionsMenu.lua, whose row sets are not the Gen 1 ones. Add rows by value rather than by index.
  • The new-game speech keeps Gen 1's four intro.oak_speech.* names and payload keys even though Gold's scene is Elm's, because it is the same moment: the intro asking for the answers a save is built from.