Repository navigation
Guide Portable Mods
One mod, one entry file, running on Red/Blue/Yellow, Gold/Silver/Crystal and
FireRed/LeafGreen/Ruby/Sapphire/Emerald. This is mod API 3. It is opt-in: an
api 1 or 2 mod behaves exactly as before. For the model behind it, see
Portable API 3; this page is the how-to.
Set "api": 3 and list the games you have tested:
{
"id": "portable_example",
"name": "Portable Example",
"version": "1.0.0",
"api": 3,
"games": ["gen1", "gen2", "gen3"],
"entry": "main.lua"
}The entry file runs once per boot, on whichever game is loaded:
return function(mod)
local mew = mod.content.pokemon:get("MEW")
if mew then
mod.content.pokemon:patch("MEW", {
baseStats = { attack = math.min(255, mew.baseStats.attack + 10) },
})
end
if mod.content.moves:get("TACKLE") then
mod.content.moves:patch("TACKLE", { power = 45 })
end
mod.events:once("world.stepped", function()
local ok, reason = mod.world:setFlag("mod:visited", true)
if not ok then mod.log:warn("%s", reason) end
end)
endThat is the engine's mods/examples/portable_api mod. Validate it and ask the
checker how far it gets on each generation:
python3 tools/modkit.py validate mods/examples/portable_api
python3 tools/modkit.py gen2check mods/examples/portable_api
python3 tools/modkit.py gen3check mods/examples/portable_apimodkit scaffold writes "api": 2 unless you pass --api 3, which writes "api": 3.
games takes version ids (red, blue, yellow, gold, silver,
crystal, firered, leafgreen, ruby, sapphire, emerald), generations
(gen1, gen2, gen3), the Gen 3 families frlg (FireRed, LeafGreen) and
rse (Ruby, Sapphire, Emerald), or all. A mod is only loaded on a game it
claims; a manifest that says nothing is Gen 1 only, and gen2compat is not a
Gen 3 claim. A claim means "I ran this there". See
Preparing your mod for Gen 2 and Gen 3
for per-game enablement and dependencies that scope themselves to a
generation.
Gen 1 is the source of truth: names, record shapes and units are the Gen 1 ones, and Gen 2 and Gen 3 translate to them at the boundary.
| Surface | Portable on all three |
|---|---|
mod.content |
pokemon, moves, items, trainers, encounters take the canonical Gen 1 record shape (and add has) |
mod.events, mod.hooks
|
shared names; payload records are converted at the boundary |
mod.world |
current, party, setFlag, getFlag, warpTo, spawnNpc, queueScript, startWildBattle, field actions and the rest of the common set |
mod.battle |
snapshot() and submit() intents |
mod:supports |
the capability query, below |
Details worth knowing:
- Species use
baseStats.special; on Gen 2 and Gen 3 it sets both later Special stats. AddspecialAttackandspecialDefensewhen you want them to differ. Gen 1 requires them equal. - Species ids are Gen 1 spellings (
FARFETCHD,MR_MIME), Gen 3 types arePSYCHICandMYSTERY. Moves use Gen 1 effect ids such asNO_ADDITIONAL_EFFECT; an effect with no verified mapping is refused. - Registrations need no storage index. Gen 3 allocates a free slot for you.
-
register,override,patchandremovereturnnil, reasonwhen the selected game cannot represent the change. Nothing is recorded. Check the result instead of assuming. - Reads and iteration return detached copies. Change data only through a write method.
local ok, reason = mod:supports("content", "pokemon")
local canFly = mod:supports("world", "flyTo")
local hasHook = mod:supports("hooks", "core.logic_speed")
local hasEvent = mod:supports("events", "world.stepped")Surfaces are content, world, hooks and events. Hook and event answers
are per version and follow native call sites, so a Gen 1 baseline name is true
only where that generation has the site (battle.style, catch.nickname and
catch.party_full are Gen 1 only; transition.style is Gen 1 and Gen 2;
mod.options_changed is true everywhere). Phone, radio and the intro cinema are Gold/Silver/Crystal only.
shiny.roll, gender.roll, held_item.trigger, breeding.compatibility,
egg.hatched, happiness.changed, pokerus.infected and the roamer events
are true on Gen 2 and Gen 3. Custom mod.<id>.* events are always true.
hooks:wrap, events:on and events:once accept any catalogued name on any
game, so one file loads everywhere; a subscription to a name the game never
raises just never fires. Unknown names raise. Gate behavior on mod:supports,
not on a version string.
Shared names are registrable everywhere, but a feature only does something where the cart has it. The API never invents gameplay: there is no Gen 3 phone, radio or apricorns.
| Area | Gen 1 | Gen 2 | Gen 3 |
|---|---|---|---|
| Species, moves, items, trainers, encounters | yes | yes | yes |
| Encounter writes | one table | a shared write applies to every time of day | translated to the Gen 3 encounter tables |
Text (text, strings, tokens) |
yes | yes |
tokens expand in text; text takes the native shape |
rom_text |
no | yes | yes, native shape |
Audio (music, sfx, cries, map_songs) |
yes | yes |
file and chip rows; a row keyed by a native name replaces the cart sound |
sprites, icons, palettes, font
|
yes | yes (sprites, icons, palettes pass through in the Gold shape) |
yes (native shape; Gen 3 rules below) |
type_chart, growth_rates, evolution_methods, item_effects
|
yes | yes | yes |
balls |
yes | yes | yes, native shape |
move_effects, statuses, ai_classes
|
yes | yes | yes, native shape on Gen 3 |
battle_anims |
yes | yes, native Gen 2 shape | yes, native Gen 3 shape |
decorations, landmarks
|
no | yes | landmarks yes; decorations on Ruby, Sapphire and Emerald only |
screens and mod.ui
|
yes | yes | yes, drawn inside the GBA frame |
apricorns, phone_contacts, radio_channels, held_items
|
no | yes | no |
rulesets, text_pointers, link_fields
|
yes | no | no |
tilesets, field, constants, audio, battle_sprite_scales, transitions
|
yes | yes | no Gen 3 target |
A write to a registry with no target on the running game is dropped and reported once in the manager's error feed. It is not fatal, so register the content that applies and let the rest go. The exact routing per generation is in Reference: registries and the generation notes in the Cookbook.
Some registries have a different shape on each generation and no canonical
record adapter. An api 3 mod still writes them: the record passes through in
the shape the running game uses (battle_anims, constants, icons,
map_scripts, palettes, sprites, balls, maps, plus statuses,
move_effects and ai_classes), wherever the game routes the registry. Put
the generation's native record in the call, and branch on mod:supports or the
generation when the shapes differ. A registry with no target on the running game
(see the table above) answers false with a reason and the write is dropped.
Always check mod:supports rather than this table; it is the live answer.
The Gen 3 statuses, move_effects and ai_classes registries accept native
Gen 3 records (see the Gen 3 notes in the Cookbook).
Gen 3 statuses live in their own per-battler slot, so a mon holds at most one
status of either kind.
-
Sprites. A
spritesrecord draws through the field renderer for any NPC whosespritenames it (mod.world:spawnNpc,npc:setAppearance). Gen 3 art is full colour, so mod sheets draw as true colour;trueColoris accepted and changes nothing, andpaletteSourcehas no meaning. Species battle pictures come from thepokemonrecord (spriteFront,spriteBack, 64x64). -
Palettes. Four-row records are applied as a luminance ramp to the
cache's baked art. Ids are
<SPECIES>MON,<SPECIES>MON_SHINYandOBJ_EVENT_GFX_<NAME>. Register them; there is no vanilla record to override. A four-colour ramp cannot reproduce a multi-hue native palette. -
Fonts.
fontrecords keep the Gen 1 shape: page alpha is the glyph mask in 8x8 cells, and multi-charactercharmap:rows are ignored. -
Trainers. Rows may carry
className,class,baseMoneyand apicimage path. -
Balls.
ballstake{ multiplier, autoCatch }. -
Item effects.
use(ctx)returns"consumed","kept"or"failed"plus messages, through the native item flow;ctx.targetis the native Gen 3 party record. - Tilesets are not available: Gen 3 maps are 16 px metatiles baked per tileset pair, with no per-tile block table to replace.
-
Blocks.
activeBlockAtandreplaceBlockuse 16 px zero-based blocks on Gen 3 and 32 px on Gen 1 and 2.
mod.world:queueScript(rows, { onDone = function(ok, reason) end }) runs a
list of { verb, arg... } rows on every generation. The whole list is
validated before anything runs, waits for native completion between
asynchronous rows, and is capped at 1024 rows.
mod.world:queueScript({
{ "show_text", "Take this." },
{ "give_item", "POTION", 1 },
{ "set_flag", "mod:gift_given" },
}, { onDone = function(ok, reason)
if not ok then mod.log:warn("%s", reason) end
end })Verbs available on all three:
| Group | Verbs |
|---|---|
| Flow |
label, jump, jump_if_true, jump_if_false, end, wait, wait_flag
|
| Checks |
check_flag, check_item, check_money, check_dex_owned, check_battle_result
|
| State |
set_flag, clear_flag, set_field, set_option, give_money, take_money, take_coins, give_item, take_item, heal_party, mark_seen, give_pokemon
|
| Dialogue |
show_text, ask, choice
|
| World |
warp, move_player, move_npc, move_npc_to, face, face_object, face_npc, face_player, face_player_dir, place_npc, show_object, hide_object, replace_block
|
| Battle and shops |
start_battle, static_battle, open_mart
|
| Audio |
play_sound, play_music, stop_music, play_cry, play_once, text_sound, wait_sound, engage_music, fade_music, play_default_music, remember_outdoor
|
text, setflag and clearflag are aliases of show_text, set_flag and
clear_flag.
Adapted verbs depend on what the native game can do:
| Verb | Gen 1 | Gen 2 | Gen 3 |
|---|---|---|---|
emote, fade, walk_npc, text_opts, push_screen, trade
|
native command set, if it has the verb | adapted | adapted |
pan_camera, march_in_place
|
native command set, if it has the verb | native command set, if it has the verb (not adapted) | adapted (the only generation that adapts them) |
Anything else is refused before the first row runs, with
unsupported script command: <verb> or native adapter unavailable: <verb>.
On Gen 2 and Gen 3 text_opts accepts only auto. push_screen needs a
screen registered in content.screens. give_item and static_battle
resolve ids through the running game's data, and an unknown id refuses the
whole list. Native script bytecode stays version-specific: a Gold map_scripts
row or a Gen 3 scriptKey is not portable.
Flags written with a mod: prefix are private booleans in your mod's save
bucket on every generation. Symbolic native flags need constants for the
version; Crystal does not reuse Gold's.
mod.ui.push(game, id, ...) pushes a screen registered in content.screens. On
Gen 3 there are no builtin Gen 1 screens, so an unregistered id raises. The Gen 3
bridge belongs to api 3 mods; api 1 and 2 mods keep the Screens-based push.
A screen state has enter, update, draw, exit and optional isOpaque.
On Gen 3 it draws into the 160x144 interior of the 240x160 GBA frame,
translated to (40, 8). Set gbaCanvas = true on the state to draw in the
full 240x160 space. screen.pushed and screen.popped are raised.
mod.ui.TextBox.new(game, text, onDone, opts) opens the native message
window. On Gen 3, opts.choice and opts.stay raise an error; use the ask
or choice script rows for questions. The tutorial on
custom screens is Red-only; draw with
coordinates relative to the interior and test on Gen 3 before claiming it.
local snapshot = mod.battle:snapshot()
if snapshot and snapshot.prompt == "menu" then
mod.battle:submit({ id = 1, revision = snapshot.revision, kind = "menu", choice = "fight" })
endUse a fresh, increasing intent id per mod. Snapshots carry player, enemy,
party, moves, items, revision, prompt, turn, catchable, plus
double, active, battlers and targets. Singles use battler ids 0 and 1;
Gen 3 doubles use 0 to 3. Mutating a snapshot changes nothing.
- Run the same body against every claimed version. The loader result, native
data changes,
unsupportedanswers, rollback and frozen content should all be asserted. -
python3 tools/modkit.py gen2check <mod>andgen3check <mod>scan for Gen 1-only module use and unbacked members. A clean scan means nothing known-broken was found, not that it works. Boot each generation. - Registering a callback only proves registration. Native coverage needs the real call site and its payload.
- For runtime NPCs, also check pool membership, collision, interaction, owner-scoped removal and session cleanup. Runtime NPCs survive map reloads in a session but are not saved: respawn on load. A permanent NPC belongs in a map content patch.
A portable mod is judged by what it registers, on the generation the room runs. See Link Compatibility.
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.
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