Skip to content

Cookbook Battle And Mechanics

bryanthaboi edited this page Sep 9, 2026 · 7 revisions

Cookbook — battle and mechanics

Start with a data change before adding a callback. Random battle results need a controlled inspection or repeated comparison.

Setup: a working mod from Getting Started. Examples target Red/local single-player unless a recipe explicitly says otherwise. Use Modding Basics for syntax and Glossary for unfamiliar terms. Save and restart after content edits. A validation pass and an in-game checkpoint are different checks.

Where code goes: insert Lua fragments inside your existing return function(mod) ... end in main.lua. A complete-file label or named other file overrides that default. Do not paste a second entry function inside the first. Read the recipe's requirements before copying its code. For output, use Read log output; for IDs, use Find internal IDs.

Hook contracts: Reference: Hooks; worked path: Tutorial 10.

R19 — Add a custom AI brain

Advanced callbacks. Before you paste: Use a Red save before Brock and understand functions/loops. A brain chooses enemy actions; begin with the simple deterministic first-usable-move rule shown here.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

mod.content.trainers:patch("OPP_BROCK", {
  brain = function(battle)
    -- always pick the enemy's first usable move
    for _, mv in ipairs(battle.enemy.curMoves) do
      if mv.pp > 0 then return mv end
    end
  end,
})

Checkpoint: Brock picks moves your way; a nil return falls back to Struggle handling. A brain supersedes the class action and move scoring entirely (src/battle/BattleState.lua vanillaEnemyAction). Reusable brains live in the ai_classes registry (kind = "brain"), named from a trainer's aiClass; the battle.enemy_action hook intercepts every trainer at once.

R27 — React to a battle event

Events. Before you paste: Start a battle and choose a move while watching terminal log output. The event reports a completed move-use action; the listener does not change damage.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

mod.events:on("battle.move_used", function(ev)
  mod.log:info("%s used %s", ev.user.name, ev.move.name)
end)

Checkpoint: a log line per move. The full battle event set — battle.started, battle.turn_started/turn_ended, battle.damage_dealt, battle.fainted, pokemon.caught, ... — is in Reference: Events.

R28 — Wrap the damage hook

Hooks. Before you paste: Read the hook contract first. next(ctx) runs the remaining calculation; this wrapper keeps both returned values and scales enemy damage.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

mod.hooks:wrap("battle.damage", function(next, ctx)
  local dmg, info = next(ctx)
  if not ctx.user.isPlayer then dmg = math.floor(dmg * 12 / 10) end
  return dmg, info
end)

Checkpoint: enemy hits land 20% harder; with the mod disabled the chain is empty and numbers are vanilla. Return both values — the second is { crit, typeMult }.

R29 — Add a status condition

Advanced mechanics. Before you paste: Create a move effect that inflicts CURSE as described below. Registering the status alone does not apply it to anyone. HUD means the battle information display.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

mod.content.statuses:register("CURSE", {
  label = "CURSE", hudLabel = "CRS",
  residual = function(battler)
    local mon = battler.mon
    mon.hp = math.max(0, mon.hp - math.max(1, math.floor(mon.stats.hp / 16)))
    return { ("%s's\nhurt by the curse!"):format(battler.name) }
  end,
  catchBonus = 12, cureOnSwitch = true,
})

Checkpoint: a move effect calling ctx.inflict(ctx.target, "CURSE") ticks residual damage each turn and shows CRS on the HUD. One record feeds the HUD, the catch math, and the turn loop (src/battle/Status.lua).

R30 — Add a type + chart row

Basic Lua + mechanics. Before you paste: Assign FAIRY to a move and use it on a Dragon target to exercise this matchup. The two registrations only define the type and matchup.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

mod.content.type_chart:register("FAIRY", { category = "special" })
mod.content.type_chart:register("FAIRY>DRAGON", { multiplier = 20 })

Checkpoint: FAIRY hits Dragons super-effectively. multiplier is base 10; bare ids are types (mind PSYCHIC_TYPE), "A>B" ids are matchup rows — one registry holds both.

R36 - Scale a battle sprite

Basic Lua + art. Before you paste: Choose a species or an explicit image path. Battle-scale numbers multiply image dimensions; they are not Pokemon stats. Test the back sprite with that species in your party.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

-- MEW's back pic renders 1.5x; its front pic is untouched
mod.content.pokemon:patch("MEW", { battleScaleBack = 1.5 })

-- or target one image by path (beats the species scale for that pic)
mod.content.battle_sprite_scales:register("abra_back", {
  path = "assets/generated/battle/back/abrab.png",
  scale = 1.5,
})

Checkpoint: the rescaled pic draws bigger with its feet still flush on the ground line. Defaults are 1x front / 2x back, exactly the Game Boy layout; battleScaleFront scales the enemy pic, battleScaleBack the player pic, both 0.25 .. 4.0. The image-level registry is the only way to reach non-species pics such as the player's trainer back sprite. Anchoring and the send-out grow are handled for you (src/battle/BattleState.lua resolveBattleScale, frontPlacement, backPlacement).

R38 — Gen-2-like ruleset toggles

Advanced mechanics. Before you paste: This copies the existing ruleset and registers a selectable variant. Choose CLEAN in Options after loading; registration alone does not select it.

Complete walkthrough / required concept.

rulesets accept unknown fields. Ship a derived ruleset (see example_weather) and gate quirks the engine already reads:

Field gen1_faithful modern_clean
oneIn256Miss true false
focusEnergyBug true false
critUsesBaseSpeed true true
enemyUnlimitedPP true false
hyperBeamSkipRechargeOnKO true false

Insert in main.lua inside the entry function (unless a filename is shown).

local base = mod.content.rulesets:get("gen1_faithful")
local clean = {}
for k, v in pairs(base) do clean[k] = v end
clean.name = "CLEAN"
clean.oneIn256Miss = false
clean.focusEnergyBug = false
clean.enemyUnlimitedPP = false
clean.hyperBeamSkipRechargeOnKO = false
mod.content.rulesets:register("clean_gen1", clean)

Checkpoint: pick CLEAN in Options → RULESET; enemies spend PP, 1/256 misses are gone, and Hyper Beam always recharges. Type-chart Gen-2 fixes go through mod.content.type_chart separately.

R39 — Shiny indicator overlay

Advanced rendering. Before you paste: DV means determinant value. The marker only appears for a creature whose DVs satisfy the shiny check; ordinary encounters are not a dependable immediate test.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

local Stats = require("src.pokemon.Stats")
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
    mod.ui.Font.draw("*", 8, 8)  -- stand-in sparkle
  end
end)

Checkpoint: a gift / stationary / fishing mon with shiny DVs shows the marker; random grass encounters almost never will (Gen 1 DV odds).

R49 — Pick Pokémon sprites on the fly

Advanced outline. Before you paste: This is only the rendering half of a skin selector. Supply the named PNGs and your own supported way to store/read a creature's skin choice; no picker or persistence is created here.

Complete walkthrough / required concept.

Content freezes after load, so you cannot pokemon:patch a skin mid- session. Use pokemon.sprite (and pokemon.icon for the party menu):

Advanced rendering excerpt — supply the skin state and assets first.

-- store the player's pick on the mon somehow (mod.save, a link_fields
-- bag field, nickname tag, …); this example reads mon.skin
mod.hooks:wrap("pokemon.sprite", function(next, path, ctx)
  path = next(path, ctx)
  local skin = ctx.mon and ctx.mon.skin
  if not skin then return path end
  ctx.trueColor = true
  local side = ctx.side == "back" and "back" or "front"
  return mod.assets:path(("skins/%s_%s_%s.png"):format(ctx.species, skin, side))
end)

mod.hooks:wrap("pokemon.icon", function(next, path, ctx)
  path = next(path, ctx)
  local skin = ctx.mon and ctx.mon.skin
  if not skin then return path end
  return mod.assets:path(("skins/%s_%s_icon.png"):format(ctx.species, skin))
end)

Checkpoint: after setting mon.skin = "alt", the summary / dex / battle pics and party icon use the alternate art wherever the hook receives that Pokemon instance. Views without a ctx.mon fall back to the normal art. Battle pics resolve when the battler is built — switch out and back (or start a new fight) to refresh mid-battle.

R50 — Swap the player's trainer art

Art or advanced hooks. Before you paste: Choose one route: fixed original PNGs, paths for an authored hero, or a saved outfit selection. Each still lives in a valid mod folder with a manifest.

Complete walkthrough / required concept.

The player's own pics are three assets: the battle back pic (battle/redb.png), the catch tutorial's old man (battle/oldmanb.png), and the front pic the intro, trainer card and Hall of Fame share (trainer_card/red.png). Three routes reach them, cheapest first.

One flat replacement, no code. Drop the PNGs in overrides/; they shadow the generated cache wherever those pics are drawn:

mods/my_mod/overrides/battle/redb.png
mods/my_mod/overrides/trainer_card/red.png

Your own art, by path. A total conversion that ships a different hero points field.playerPics at it instead — no dependency on the vanilla path layout:

Insert in main.lua inside the entry function (unless a filename is shown).

mod.content.field:patch("playerPics", {
  back  = mod.assets:path("art/hero_back.png"),
  front = mod.assets:path("art/hero_front.png"),
})

Per-save or conditional. field freezes after load, so a pic that follows an outfit / gender / story choice goes through the player.sprite hook, which stays live:

Insert in main.lua inside the entry function (unless a filename is shown).

mod.hooks:wrap("player.sprite", function(next, path, ctx)
  path = next(path, ctx)
  if ctx.demo then return path end
  local outfit = mod.save:get("outfit") or "default"
  return mod.assets:path(("art/%s_%s.png"):format(outfit, ctx.side))
end)

Back pics are drawn at 2x with their feet flush on the text-box top, and the engine measures your PNG's transparent rows to place it — so art of any height grounds correctly with no offsets to tune. To draw it at another size, register your path (not the vanilla one) in battle_sprite_scales:

Insert in main.lua inside the entry function (unless a filename is shown).

mod.content.battle_sprite_scales:register("hero_back", {
  path = mod.assets:path("art/hero_back.png"),
  scale = 2.5,
})

Checkpoint: start any battle — the new back pic slides in with the intro, stands with its feet on the text box, and clears on "Go!". The trainer card and Hall of Fame show the new front pic. Setting the outfit key mid-game changes the pic from the next battle on.

R53 - Gen-2-style battle QoL toggles

UI hooks. Before you paste: These convenience hooks target Gen 1. Catch a species before testing its owned marker; the flag is not shown for every wild encounter.

Complete walkthrough / required concept.

Three opt-in presentation hooks default to vanilla (false) and turn on per frame when a wrapper says so:

Insert in main.lua inside the entry function (unless a filename is shown).

-- the caught Ball marker beside a wild enemy's name (asked only when
-- the species is already owned)
mod.hooks:wrap("battle.caught_marker_visible", function(next, state)
  return true
end)

-- left/right grid movement on the move list and the battle party menu
mod.hooks:wrap("battle.move_grid_navigation", function(next, state)
  return true
end)
mod.hooks:wrap("ui.party.grid_navigation", function(next, menu)
  return true
end)

Checkpoint: a wild encounter with an owned species shows the Ball marker by the name; LEFT/RIGHT hop columns in the move list and the in-battle party menu. All three are Gen 1 call sites only for now (Reference: Hooks).

R56 — Color a trainer portrait, and give one party a real name

Basic Lua + art. Before you paste: Use the Gen 1 trainer class shown. Party indexes identify different teams in that class, not positions within one team.

Complete walkthrough / required concept.

Gen 1 trainers are a class and nothing else: one portrait, one palette picked by the battle intro (the hardware's MEWMON row), and one name for every party the class fights. Two record fields lift both limits.

A palette of your own. trainers.palette takes a palettes id, the same per-record override species already carry, and wins over both paletteSource and the MEWMON fallback:

Insert in main.lua inside the entry function (unless a filename is shown).

mod.content.palettes:register("BELTPAL",
  { {255,255,255}, {200,120,60}, {90,60,30}, {0,0,0} })

mod.content.trainers:patch("OPP_BLACKBELT", {
  palette = "BELTPAL",
  trueColor = false,  -- keep the 4-shade remap; true skips it entirely
})

A full-color portrait wants trueColor = true and no palette at all — that flag skips the GBC/SGB quantize and draws the PNG as authored.

A name per party. trainers.partyNames maps a party index to a personal name, the [class] [name] split Gen 2 has and Gen 1 does not. Sparse is fine — an index with no entry keeps the bare class name:

Insert in main.lua inside the entry function (unless a filename is shown).

mod.content.trainers:patch("OPP_BLACKBELT", {
  partyNames = { [1] = "TAKESHI", [3] = "YOSHI" },
})

Party 1 now introduces itself as BLACKBELT TAKESHI and carries that name through every battle line — the intro, the switch-in, the defeat. Party 2 is still plain BLACKBELT. The composed name is built per battle, so the class record itself is never rewritten; className and personalName stay separately readable on the battle's trainer table for anything that wants the halves.

Gold needs neither field: its trainer classes already carry one record per named trainer, and its palettes are patched through the trainers context of the palettes registry.

Checkpoint: the class fights in your colors, and its first party is introduced by name.

Clone this wiki locally