Skip to content

Cookbook Battle And Mechanics

bryanthaboi edited this page Jul 27, 2026 · 4 revisions

Cookbook — battle and mechanics

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

R19 — Add a custom AI brain

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

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

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

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

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

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

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

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

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

-- 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 all show the alt art without restarting. 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

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:

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:

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:

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.

Clone this wiki locally