Skip to content

Reference Hooks

bryanthaboi edited this page Jul 27, 2026 · 4 revisions

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.

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)

Scripts, saves, UI, link

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).

Clone this wiki locally