9 Reference Hooks
bryanthaboi edited this page 2026-10-20 17:01:40 -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.

Find a hook by task

You want to change Look in
Battle math, catches, trainer parties, or sprites Battle
Encounters, evolution, or field-move eligibility Progression and encounters
Movement, warps, palette, or time of day World
Music, rendering, or an overlay Presentation and audio
Scripts, save flow, menus, or cart link fingerprints Scripts, saves, UI, link
Shininess, gender, held items, breeding, the phone Gen 2 and Gen 3 hooks
Window layout, raw keyboard, gamepad and wheel input Engine, input and process lifecycle

Where code goes: call mod.hooks:wrap in your mod entry file. Status: each hook table is the exact contract. For an ordinary value-returning hook, call next, keep its result, and return that result unless you intentionally change it. This is not a universal rule: use the documented contract for each hook, especially trainer.before_battle, draw-only hooks, and hooks whose return is “nothing.”

In a signature such as ctx = { battle, move }, ctx is a table with those named fields. Multiple names in a Returns cell mean the wrapper returns those values in that order. A | B means either form is accepted.

Which games raise it

Hook names are shared across generations; nothing is prefixed per game. The Games column uses 1 = Red, Blue, Yellow, 2 = Gold, Silver, Crystal and 3 = FireRed, LeafGreen, Ruby, Sapphire, Emerald. all* marks a seam in shared engine code (audio, renderer, state stack, launcher, link fingerprint). The rows are held to the source by tests/engine/gate_gen2_mod_api.lua and tests/engine/gate_gen3_mod_api.lua, which fail when a call site appears or disappears without the list changing.

  • Wrapping a hook a game never calls is legal and the wrapper simply never fires, so one source file loads on every game. A subscriber needs no generation branch and no engine_internals permission.
  • Where a payload differs by generation the page says so: Gen 2 adds keys beside the Gen 1 ones, and Gen 3 passes native Gen 3 battle and party records. Branch on mod.generation or on the keys you find.
  • On a mod with "api": 3, mod:supports("hooks", name) answers per game version and the boundary converts record-valued payloads to the portable shape (Portable API 3).
  • Gen 3 has no phone, radio or apricorns, and no world.tod, map.palette, transition.style or intro.oak_speech.build seam; those stay Gen 1 and Gen 2 hooks.
  • Hooks marked Gen 2 and Gen 3 only exist because both generations have the mechanic (shininess, gender, held items, breeding, the enemy's switch or item turn). See Gen 2 and Gen 3 hooks.
  • battle.style, catch.nickname, catch.party_full and transition.style are in the portable catalog, and mod:supports("hooks", name) answers from their call sites: the first three are true on Gen 1 only, transition.style on Gen 1 and Gen 2. Wrapping one on a game without the site is legal and never fires. mod.options_changed is likewise catalogued and true on every game.
  • For the parts of the engine where a Gen 1 mod stops working unchanged, read Preparing Your Mod for Gen 2.

Battle

Hook Games Wrapper receives (after next) Returns Call site
battle.accuracy 1 2 3 ctx = { battle, ruleset, move, user, target, rng } true on hit src/battle/BattleState.lua (accuracyRoll)
battle.damage 1 2 3 ctx = { battle, ruleset, user, target, move, opts, rng } damage, { crit, typeMult } src/battle/BattleState.lua (computeDamage)
battle.crit 1 2 3 ctx = { ruleset, attacker, moveId, rng, highCrit } true on crit src/battle/Damage.lua
battle.turn_order 1 2 3 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 1 2 3 battle the enemy action src/battle/BattleState.lua (enemyAction - the whole AI choke point)
battle.run 1 2 3 ctx = { battle, pSpd, eSpd, attempts, rng } true on escape src/battle/BattleState.lua (runRoll)
catch.rate 1 2 3 ball, mon, def, opts = { rng, rateOverride, battle } caught, shakes src/battle/BattleState.lua (catchAttempt)
trainer.party 1 2 3 trainerClass, partyIndex, party the party list ({ { species, level }, ... }) src/battle/BattleState.lua (trainer battle setup)
trainer.before_battle 1 2 3 game, context = { trainerClass, partyIndex, mapId, npcId }, continue true to claim the engagement; anything else starts it now src/world/OverworldController.lua (prepareTrainerBattle); Gold src/world/gen2/World.lua; Gen 3 src/core/game3/battle_bridge.lua
exp.gain 1 2 3 ctx = { defeatedDef, level, isTrainer, participants, traded, mon } the exp number src/battle/Experience.lua
battle.exp_award 1 2 3 ctx = { battle, participants, alive, applyShare } nothing: award exp via ctx.applyShare(mon, split, announce); next(ctx) is the whole vanilla participant/EXP.ALL split src/battle/BattleState.lua (awardExp); Gold: src/battle/gen2/Battle.lua
battle.catch_exp 1 2 3 ctx = { battle } true to grant exp for a successful catch (vanilla catches never do) src/battle/BattleState.lua (catch path); Gold: src/battle/gen2/Battle.lua
battle.low_health_alarm 1 2 3 ctx = { on, battle } nothing: vanilla starts/stops the siren loop from ctx.on; wrap to reshape the toggle src/battle/BattleState.lua; Gold adds ctx.data (src/ui/gen2/BattleState.lua)
battle.menu_auxiliary 1 2 3 game, context = { kind = "wild"|"trainer" } true to consume START at a settled player-decision boundary; default false, START stays inert src/battle/BattleState.lua (battle menu, gated by BattleSafety.inspect)
battle.bottom_ui_visible 1 2 3 state boolean, default true; false hides the battle text/menu layer. Text boxes pushed above a battle inherit a false result src/battle/BattleState.lua; Gold: src/ui/gen2/BattleState.lua
battle.charge_required 1 2 3 ctx = { battle, user, target, move, charge = true, isCalled } ctx.charge result: false skips only the initial charge turn and continues through the ordinary move pipeline; call next(ctx) to keep it. Not asked on the release turn or when the ruleset already skips charging (Solarbeam in sun) src/battle/BattleState.lua; Gold src/battle/gen2/Battle.lua; Gen 3 src/core/game3/battle/engine.lua
battle.status_hud_visible 1 2 3 state boolean, default true; false hides the HP/status panels same sites as battle.bottom_ui_visible
battle.caught_marker_visible 1 2 3 state (the battle) true draws a caught Ball marker beside the enemy name; asked only in wild/Safari battles when the species is already owned; default false src/battle/BattleState.lua (caughtMarkerVisible)
battle.move_grid_navigation 1 2 3 state true enables left/right grid movement on the move list (already on in the wide layout); default false src/battle/BattleState.lua (moveGridNavigation)
battle.field_residual 1 2 3 context = { field = { weather, tokens }, battlers = { player, enemy }, turn } (detached, data only) a list of rows { side = "player"|"enemy", amount, message? }; start from next(context). amount is a positive integer, damage is clamped to current HP, invalid rows are ignored src/battle/BattleState.lua (after status residuals, before field-token expiry and battle.turn_ended); Gold src/battle/gen2/Battle.lua (applyFieldResiduals); Gen 3 src/core/game3/battle/residuals.lua
battle.enemy_switch_or_item 2 3 battle true when the foe spent the turn on a switch or item, or an action table { kind = "switch", index } / { kind = "item", item } for the engine to perform. Link battles never ask Gold src/battle/gen2/Battle.lua (enemyTrySwitchOrItem); Gen 3 src/core/game3/battle/ai.lua (switchOrItem)
battle.style 1 battle "set" or "shift"; anything else is the vanilla OPTION answer src/battle/BattleState.lua (the free-switch offer after the foe faints)
catch.nickname 1 mon, ctx = { battle, name, game } false keeps the species name, a string is the nickname (clipped to 10 characters, no prompt), anything else asks as usual src/battle/BattleState.lua (AskName after a capture)
catch.party_full 1 ctx = { battle, mon, name, game } truthy takes custody (nothing is deposited; pokemon.caught reports destination = "mod"), falsy deposits to the box as the cart does src/battle/BattleState.lua (before SendNewMonToBox)
pokemon.sprite 1 2 3 path, ctx = { species, side = "front"|"back", kind, mon?, trueColor, data } image path; may set ctx.trueColor src/pokemon/Sprites.lua (battle + menus)
pokemon.icon 1 2 3 path, ctx = { species, mon, name?, data, kind = "icon" } party-icon image path src/pokemon/Sprites.lua / PartyMenu
player.sprite 1 2 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)

Deferring a trainer battle

trainer.before_battle is the gate every overworld trainer engagement passes through - the sight-line walk-up and an A press on a trainer both reach it (OverworldState.prepareTrainerBattle, src/world/OverworldController.lua). It is the one hook on this page whose wrapper is handed a continuation instead of a value:

mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
  -- context = { trainerClass, partyIndex, mapId, npcId }, data only
  if context.trainerClass ~= "OPP_YOUNGSTER" then return next() end
  continue({ playerPartyIndices = { 1 } })   -- ace-only duel
  return true
end)

Return true and the engagement is yours: the engine builds no battle and waits for continue. Return anything else - or just call next - and the battle starts immediately, exactly as vanilla. continue is one-shot: the first call wins and returns true, every later call returns false, so one engagement can never open two battles. continue({ cancel = true }) calls the encounter off instead; on the sight-line route the engine remembers that refusal against npcId and the player's cell, so the trainer does not re-engage on the tile the player is standing on.

The hook has call sites on all three generations: Gold raises it from the trainer-script path (src/world/gen2/World.lua) and Gen 3 from src/core/game3/battle_bridge.lua, each with the same game, context, continue arguments.

options is the table BattleState.newTrainer receives. The one key it reads today is playerPartyIndices: an ordered, dense, duplicate-free list of save.party indices that scopes the fight to part of the party (src/battle/BattleState.lua). A malformed scope is logged and ignored, and the full party fights.

context is data only - ids and indices, no live objects - and npcId is the stable object id (Map objects and object ids), so it is also the key to hang per-NPC state off. The hook is cold on a no-mod boot: with nothing wrapped, the engine skips the path entirely.

Progression and encounters

Hook Games Wrapper receives Returns Call site
evolution.check 1 2 3 game, mon, evo, trigger true when the evolution fires src/pokemon/Evolution.lua (pendingFor)
encounter.roll 1 2 3 encDef, ctx = { mapId, terrain, rng } an encounter ({ species, level }) or nil src/world/OverworldController.lua (rollEncounter)
encounter.species 1 2 3 enc, ctx the (possibly rewritten) encounter src/world/OverworldController.lua (after a non-nil roll, before repel filtering)
encounter.table 1 2 3 dist, ctx = { mapId, terrain, preview = true } (no rng) the distribution { [species] = weight }; drives mod.world:effectiveEncounters src/world/WorldAPI.lua; Gold src/world/gen2/WorldAPI.lua; Gen 3 src/world/game3/WorldAPI.lua
encounter.fishing 1 2 3 rod, mapId, candidates an encounter or nil src/world/OverworldController.lua (goFishing)
fieldmove.eligibility 1 2 3 moveId, ctx = { save, data } the qualifying party mon, or nil; next(moveId, ctx) is the vanilla badge-gate + party-moves check, so call it first to keep vanilla answers winning src/world/OverworldController.lua (partyKnows); Gold: src/world/gen2/FieldMoves.lua (partyMoveUser)

World

Hook Games Wrapper receives Returns Call site
movement.collision 1 2 3 allowed, ctx = { map, mover, dir, fromX, fromY, toX, toY, reason } boolean; may rewrite ctx.reason src/world/Collision.lua (canMove)
movement.speed 1 2 3 frames, ctx = { onBike, surfing, player, input, save } step duration in frames (≥1) src/world/Player.lua (tryMove - running shoes, dash)
world.talk 1 3 ow, target (Gen 3: the game and the object) nothing; skip next to answer for an object the mod owns, call next(ow, target) for the normal A press src/world/OverworldController.lua (before the map's text tables); Gen 3 src/core/game3/field.lua
world.follower.spawn 1 2 3 game, ow boolean: false suppresses the follower on this map load or step src/world/PikachuFollower.lua; Gold src/world/gen2/Follower.lua; Gen 3 src/world/game3/Follower.lua
warp.destination 1 2 3 destMap, x, y, ctx = { warp, lastMap, data } destMap, x, y src/world/Warp.lua (destination)
map.palette 1 2 name, map, ctx = { tod } a palette name src/world/OverworldController.lua
world.tod 1 2 tod, ctx = { map, mapId, x, y, steps } period string ("DAY", "NIGHT", …) src/world/OverworldController.lua (timeOfDay)
zoom.range all* 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 Games Wrapper receives Returns Call site
music.select all* 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 all* 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 1 2 ctx = { trainer, stronger, dungeon, game } a transitions registry id src/render/BattleTransition.lua (an unknown style falls back to vanilla)
render.zones 1 2 3 game, zones the zones list src/core/Game.lua (palette-zone pass before the blit)
render.letterbox 1 2 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)
render.viewport all* ctx = { width, height, pixelWidth, pixelHeight, dpiX, dpiY, generation } { x, y, width, height, capture? }: the window rectangle the game renders in (clamped to the window); capture = true forces a composition canvas. With no subscriber no canvas is allocated src/render/GameViewport.lua
render.window all* game, ctx = { canvas, x, y, width, height, windowWidth, windowHeight, dpiX, dpiY, generation } nothing (draw-only); called only while a viewport is active, after the frame is captured. next(game, ctx) draws the game at the requested origin; touch controls draw after it src/render/GameViewport.lua
render.hud 1 2 3 game, viewport = { width, height, gameX, gameY, gameWidth, gameHeight, scale, dpiX, dpiY } nothing (draw-only); window space, after the finished frame, before touch controls src/core/Game.lua / src/core/Game2.lua
render.compose 1 2 renderer, ctx true without calling next owns the whole window; next runs the normal composite to decorate around src/render/Renderer.lua (endFrame); Gold: src/core/Game2.lua
render.output_enabled 1 2 (no arguments) true only while output ownership is needed; default false src/render/Renderer.lua; Gold: src/core/Game2.lua
render.output 1 2 ctx = { canvas, width, height, gameX, gameY, gameWidth, gameHeight, scale, dpiX, dpiY, generation } true takes over the window; next(ctx) keeps normal presentation src/render/Renderer.lua; Gold: src/core/Game2.lua
screen.render_visible all* state boolean, default true; false omits the state from drawing, opacity selection and palette-zone ownership while it keeps update and input. Pure predicate: may be asked more than once per frame src/core/StateStack.lua
battle.overlay 1 2 battle nothing (draw-only) src/battle/BattleState.lua (draw - after HUDs/flash)

render.compose's ctx carries the finished worldCanvas and uiCanvas with their SGB zones / worldZones, worldActive, the frame metrics (ww, wh, pw, ph, ox, oy, vpw, vph, scale, Sx, Sy, dpiX, dpiY), renderer:blitCanvas(...) for a palette-correct blit of either canvas into an arbitrary rect, and the secondScreen bridge (available() / push(imageData, w, h) / pollTouch() / setEnabled) for a second physical display. render.output_enabled / render.output are the later seam for mods that want the engine's normal single composite rather than its separate layers: they run after registered present pipelines and before GBCFX, render.hud and touch controls, and render.compose takes precedence when it owns the frame. Enabling the seam costs a full-window canvas for that frame; with no subscriber the presentation path is unchanged.

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 Games Wrapper receives Returns Call site
script.command 1 2 3 ctx, name, args the command's jump result (nil, a label, "end") src/script/ScriptRunner.lua (every script row)
save.new_game 1 2 3 save the save table src/core/SaveData.lua (New Game skeleton)
save.write 1 2 3 game false vetoes the progress write before world state is captured or any bytes reach disk src/core/Game.lua / src/core/Game2.lua
item.use 1 3 Gen 1: game, battle, id, target, list, moveIndex, picker; Gen 3: game, nil, id, partySlot, bag the dispatch result the vanilla bag use returns; wraps the whole bag-use path so a mod can suppress a message or replace one item's outcome src/ui/BagMenu.lua (useOn); Gen 3 src/core/game3/item_use.lua (useField)
pokemon.level_visible 1 2 3 mon, ctx = { where = "battle.enemy"|"battle.player"|"party"|"summary", game } boolean, default true; only an explicit false hides the level (and its <LV> glyph) src/ui/LevelDisplay.lua
ui.start_menu.items 1 2 3 game, items the items list src/ui/StartMenu.lua
ui.title_menu.items 1 2 game, items the items list; the safe place for a tool to offer a fresh-session action before gameplay begins src/ui/TitleState.lua
ui.party.submenu 1 2 3 game, items, mon, ctx = { battle, overworld } the items list src/ui/PartyMenu.lua
ui.party.grid_navigation 1 2 3 partyMenu true enables grid movement in the battle party menu; default false src/ui/PartyMenu.lua (gridNavigation)
ui.options.rows 1 2 3 game, rows the rows list src/ui/OptionsMenu.lua
ui.pc.items 1 2 game, items the items list src/world/OverworldController.lua (the PC menu)
ui.list_menu 1 2 3 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 1 2 3 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 1 2 steps, speech the steps list src/ui/OakSpeech.lua (buildSteps)
link.fingerprint all* data, mods the digest string src/link/Fingerprint.lua (compute; low-level cart protocol, not loose-mod approval)

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

Engine, input and process lifecycle

Tool and platform-bridge seams. The frame, input and core.logic_speed hooks are raised by all three game shells (Game.lua, Game2.lua, Game3.lua); core.update and core.quit_to_launcher come from the shared launcher code.

Hook Games Wrapper receives Returns Call site
input.step 1 2 3 game, dt nothing; runs immediately before queued button edges are promoted, so input added here is visible during that same fixed step. Must call next(game, dt) src/core/Game.lua / src/core/Game2.lua
input.key 1 2 3 game, ev = { phase = "pressed"|"released", key } nothing; must call next(game, ev): next runs the engine's own handling (capture, hotkeys, Input), so a wrapper that returns without it eats the key for that frame src/core/Game.lua / Game2.lua / Game3.lua
input.gamepad 1 2 3 game, ev = { phase = "pressed"|"released"|"axis", joystick, button, axis, value } same contract as input.key same sites
input.wheel 1 2 3 game, dy true without calling next consumes; no engine behavior reads the result today same sites
input.pointer 1 2 3 game, ev = { phase, source, id, x, y, dx, dy, pressure, button } true without calling next consumes the event src/core/Game.lua / src/core/Game2.lua
core.logic_speed 1 2 3 game a GameSpeed.LEVELS multiplier for this frame; next(game) resolves the per-category GAME SPEED option. The result is clamped, and link play's 1X lock plus the --speed run argument stay unconditional and invisible to subscribers src/core/Game.lua (logicSpeed, once per frame); Game2.lua, Game3.lua
core.update all* game, dt nothing; vanilla is game:update(dt). Skip next to pause the simulation for a frame, and poll before/after it regardless src/core/PlatformHooks.lua (from love.update)
core.quit_to_launcher all* (no arguments) next() is the engine's own return-to-launcher decision; return false without calling next to let the process really quit src/core/PlatformHooks.lua (from love.quit)

input.pointer delivers uncaptured gameplay pointer events, touches and real mouse alike, in LÖVE window units (the same space render.hud's viewport uses). phase is "pressed", "moved", "released" or "cancelled"; source is "touch" or "mouse"; id is the LÖVE touch id or "mouse". The on-screen touch controls keep first refusal: a pointer that begins on a virtual control belongs to the pad for its whole lifecycle and never reaches the hook. Focus or visibility loss delivers a "cancelled" for every pointer the hook saw pressed but not yet released.

Gen 2 and Gen 3 hooks

Six hooks exist because the mechanic does: Gold, Silver, Crystal and the Gen 3 games have shininess, gender, held-item battle effects, breeding, and an AI that can spend its turn on a switch or an item. Gen 1 has none of these, so a wrapper registered on a Red, Blue or Yellow boot never fires. They are ordinary hooks: same mod.hooks:wrap call, same next contract. phone.contact_list is Gen 2 only, because only Gen 2 has the phone.

Hook Games Wrapper receives (after next) Returns Call site
shiny.roll 2 3 ctx = { dvs, species, def, level } true when the mon is shiny src/battle/gen2/Mon.lua; Gen 3 src/core/game3/pokemon.lua
gender.roll 2 3 ctx = { def, dvs, ratio, species, level } "male" / "female" / "genderless" same sites
held_item.trigger 2 3 ctx = { battle, mon, item, def, effect, parameter, trigger } effect, parameter src/battle/gen2/Battle.lua; Gen 3 src/core/game3/battle/held_items.lua
breeding.compatibility 2 3 ctx = { data, mon1, mon2, dayCare } 0-255 compatibility src/core/gen2/Breeding.lua; Gen 3 src/core/game3/breeding.lua
battle.enemy_switch_or_item 2 3 battle see the Battle table src/battle/gen2/Battle.lua; Gen 3 src/core/game3/battle/ai.lua
phone.contact_list 2 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. A forced-shiny battle (opts.shiny) is not overridden, so the cart's scripted shiny Gyarados stays shiny.
  • 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.
  • battle.enemy_switch_or_item is the companion to battle.enemy_action: that one rewrites which move the foe picks, this one decides whether the foe spends the whole turn rotating or drinking. A link battle supplies both sides' actions directly and consults neither hook, so a mod cannot desync a lockstep match through either.

trigger on held_item.trigger names the moment the item is consulted: "priority" (Quick Claw), "damage" (Scope Lens and the type-boost family), "endure" (Focus Band), "flinch" (King's Rock), "accuracy" (BrightPowder), "confuse", "residual" (Leftovers, berries, cures) and "check" for any other read. Return nil to make the item do nothing at that trigger, or another HELD_* name to substitute one. Defining a new held item is the held_items registry's job (Registries); the two compose. dayCare on breeding.compatibility is true when the question comes from the Day-Care pair rather than a speculative check.

Payload differences by generation

Shared hooks carry extra keys 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
world.trainer_engaged (event) trainerEvent and sight

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.

On Gen 3 the battle hooks receive the Gen 3 battle state and native battler records (battler.mon.hp, battler.stages, battler.status) rather than the Gen 1 battle table, and move arguments are Gen 3 move views. encounter.fishing receives the rod id, map id and pool; movement.speed wraps a step's frame count and the Hoenn bike path honours it. trainer.before_battle receives the game, a context table and a continueBattle function (see Deferring a trainer battle). Portable ("api": 3) mods get species, move and item ids converted at the hook boundary, so one wrapper reads the same record shape on every game.

Hooks with no call site on some games

A hook the game never calls is not an error; the wrapper is simply never run. The names below are the ones a Gen 1 mod is most likely to expect elsewhere:

  • battle.overlay has Gen 1 and Gen 2 sites only.
  • world.tod, map.palette, transition.style, intro.oak_speech.build, ui.pc.items, ui.title_menu.items and player.sprite have Gen 1 and Gen 2 sites; the Gen 3 shells raise none of them.
  • world.talk and item.use have Gen 1 and Gen 3 sites (Gold routes both through its script VM and its own bag).
  • render.letterbox, render.compose, render.output_enabled and render.output have Gen 1 and Gen 2 sites; Gen 3 has render.hud and render.zones only.
  • battle.style, catch.nickname and catch.party_full are Gen 1 only.
  • phone.contact_list is Gen 2 only.

Hooks whose Gold screen is a different screen

Three ui.* hooks keep their name across 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 and the Gen 3 equivalents in src/ui/game3, whose row sets are not the Gen 1 ones. Add rows by value or anchor label, not by index. The start-menu hook exposes executable onSelect rows on every generation.
  • The new-game speech keeps Gen 1's four intro.oak_speech.* event names and payload keys on Gold and Gen 3 even though the scene differs (Elm on Gold, the Gen 3 new-game scene), because it is the same moment.