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)
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).
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.rollis coerced to a boolean, andgender.rollfalls back to the vanilla roll if the wrapper returns anything that is not one of the three genders.held_item.triggermust return a string effect or the item does nothing;parameterfalls back to the vanilla value when it is not a number.breeding.compatibilityis floored and clamped to0-255.phone.contact_listmust return a table of exactlyPhone.CONTACT_LIST_SIZEentries, 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.itemsis the WHICH-PC list on Gen 1 and Bill's PC's own rows on Gold.ui.start_menu.itemsandui.options.rowsare raised over Gold's ownsrc/ui/gen2/StartMenu.luaandOptionsMenu.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.
Start here
Concepts
Tutorials
- The ladder
- 01 Sprite and Text Tweak
- 02 Balance Patch
- 03 New Species
- 04 New Item and Ball
- 05 New Move
- 06 NPC and Dialogue
- 07 New Map
- 08 Quest
- 09 Custom Music
- 10 New Mechanic
- 11 Custom UI Screen
- 12 Mini Total Conversion
Reference
Guides
- Art Pipeline
- Audio Authoring
- Preparing Your Mod for Gen 2
- Translations
- Total Conversions
- Link Compatibility
- Publishing a Mod
- Docs Style Guide
Playing
Engine 1.0.0 · mod API 2 · repository · Discord
This project ships no ROM data. All game content is decoded on the player's machine from their own verified Pokemon Red ROM; mods ship recipes and original assets, never extracted content.