-
Notifications
You must be signed in to change notification settings - Fork 16
Reference Hooks
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.
| 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)| 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) |
| 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)| Hook | Wrapper receives | Returns | Call site |
|---|---|---|---|
music.select |
song, ctx = { reason, mapId, mapSong, onBike, surfing, kind, battleKind, trainerId } |
a song id |
src/core/Music.lua (selectSong — every song choice passes through) |
music.volume |
vol, ctx = { song, mapSong, mapId, x, y, tod, onBike, surfing, fading, optionScale } |
absolute source volume (0..) |
src/core/Music.lua (applyVolume — also re-applied every frame while wrapped) |
transition.style |
ctx = { trainer, stronger, dungeon, game } |
a transitions registry id |
src/render/BattleTransition.lua (an unknown style falls back to vanilla) |
render.zones |
game, zones |
the zones list |
src/core/Game.lua (palette-zone pass before the blit) |
render.letterbox |
ctx = { ww, wh, pw, ph, ox, oy, vpw, vph, scale, dpiX, dpiY, worldActive } |
nothing (draw-only) |
src/render/Renderer.lua (endFrame — after the letterbox clear, before the game blit) |
battle.overlay |
battle |
nothing (draw-only) |
src/battle/BattleState.lua (draw — after HUDs/flash) |
Example — fade town music as you approach the edge:
mod.hooks:wrap("music.volume", function(next, vol, ctx)
vol = next(vol, ctx)
if not (ctx.x and ctx.mapId) then return vol end
-- toy: quieter when standing near the north edge
if ctx.y and ctx.y <= 2 then return vol * 0.4 end
return vol
end)Example — draw an SGB-style border in the letterbox bars:
local border
mod.events:on("game.ready", function()
border = love.graphics.newImage(mod.assets:path("assets/sgb_border.png"))
end)
mod.hooks:wrap("render.letterbox", function(next, ctx)
next(ctx)
if border then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(border, 0, 0, 0, ctx.ww / border:getWidth(),
ctx.wh / border:getHeight())
end
end)Example — shiny sparkle over the wild battler:
local Stats = require("src.pokemon.Stats") -- supported require
mod.hooks:wrap("battle.overlay", function(next, battle)
next(battle)
local mon = battle.enemy and battle.enemy.mon
if mon and Stats.isShiny(mon.dvs) then
-- draw your sparkle at the enemy HUD / pic anchor
end
end)| Hook | Wrapper receives | Returns | Call site |
|---|---|---|---|
script.command |
ctx, name, args |
the command's jump result (nil, a label, "end") |
src/script/ScriptRunner.lua (every script row) |
save.new_game |
save |
the save table |
src/core/SaveData.lua (New Game skeleton) |
ui.start_menu.items |
game, items |
the items list | src/ui/StartMenu.lua |
ui.party.submenu |
game, items, mon, ctx = { battle, overworld } |
the items list | src/ui/PartyMenu.lua |
ui.options.rows |
game, rows |
the rows list | src/ui/OptionsMenu.lua |
ui.pc.items |
game, items |
the items list |
src/world/OverworldController.lua (the PC menu) |
ui.list_menu |
opts, ctx = { game, title, kind, itemCount } |
opts table (wrap, pageJump, keyRepeat, repeatDelay, repeatRate) |
src/ui/ListMenu.lua (new — bag uses kind = "bag") |
ui.naming.grid |
grid, ctx = { lower, title, maxLen, game } |
letter-grid rows (keep an "ED" cell and a case-switch row) |
src/ui/NamingScreen.lua |
intro.oak_speech.build |
steps, speech |
the steps list |
src/ui/OakSpeech.lua (buildSteps) |
link.fingerprint |
data, mods |
the digest string |
src/link/Fingerprint.lua (compute) |
The four ui.* list hooks and intro.oak_speech.build type-check the
result: return anything but a table and the vanilla list is kept with a
logged error. Insert with the mod.ui helpers rather than raw
table.insert when position matters:
mod.hooks:wrap("ui.start_menu.items", function(next, game, items)
mod.ui.insertBefore(items, "QUIT", {
label = "QUESTS",
onSelect = function() mod.ui.push(game, "QuestLog") end,
})
return next(game, items)
end)Example — bag wrap + hold-to-scroll + page jump:
mod.hooks:wrap("ui.list_menu", function(next, opts, ctx)
opts = next(opts, ctx) or opts
if ctx.kind == "bag" then
opts.wrap, opts.pageJump, opts.keyRepeat = true, true, true
end
return opts
end)Example — add digits to the naming grid:
mod.hooks:wrap("ui.naming.grid", function(next, grid, ctx)
grid = next(grid, ctx)
local out = {}
for i, row in ipairs(grid) do out[i] = row end
out[4] = { "0", "1", "2", "3", "4", "5", "6", "7", "8" }
return out
end)Entries are { label, onSelect }; anchors are stable labels
("POKéDEX", "OPTION", "QUIT", ...), and a missing anchor appends.
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 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.
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
- Translations
- Total Conversions
- Link Compatibility
- Publishing a Mod
- Docs Style Guide
Playing