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_internalspermission. - 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.generationor 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.styleorintro.oak_speech.buildseam; 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_fullandtransition.styleare in the portable catalog, andmod:supports("hooks", name)answers from their call sites: the first three are true on Gen 1 only,transition.styleon Gen 1 and Gen 2. Wrapping one on a game without the site is legal and never fires.mod.options_changedis 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)
Scripts, saves, UI, link
| 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.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. A forced-shiny battle (opts.shiny) is not overridden, so the cart's scripted shiny Gyarados stays shiny.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.battle.enemy_switch_or_itemis the companion tobattle.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.overlayhas Gen 1 and Gen 2 sites only.world.tod,map.palette,transition.style,intro.oak_speech.build,ui.pc.items,ui.title_menu.itemsandplayer.spritehave Gen 1 and Gen 2 sites; the Gen 3 shells raise none of them.world.talkanditem.usehave Gen 1 and Gen 3 sites (Gold routes both through its script VM and its own bag).render.letterbox,render.compose,render.output_enabledandrender.outputhave Gen 1 and Gen 2 sites; Gen 3 hasrender.hudandrender.zonesonly.battle.style,catch.nicknameandcatch.party_fullare Gen 1 only.phone.contact_listis 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.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.luaand the Gen 3 equivalents insrc/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 executableonSelectrows 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.
Start here
Playing
Tutorials
- Choose a tutorial
- 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
Creative guides
Concepts
Recipes and reference
Author and developer tools
Home · Start Playing · Glossary · Troubleshooting · Repository · Community
Game data is imported locally from a supported ROM supplied by the player. Shared mods contain original work or transform recipes, never extracted game content. Check each guide's target game before using its examples.