Portable API 3
One entry file, every game. A mod that declares "api": 3 and lists the games
it claims runs on Red, Blue, Yellow, Gold, Silver, Crystal, FireRed, LeafGreen,
Ruby, Sapphire and Emerald with the same names, record shapes and units.
Gen 1 is the source of truth: Gen 2 and Gen 3 add capabilities and keep the
inherited contracts. Shared names are registrable everywhere, but a feature only
does something where the cart has it. The API never invents gameplay a game
lacks (no Gen 3 phone, radio or apricorns).
api 1 and api 2 mods behave exactly as before. API 3 is a per-manifest
opt-in, not a rewrite of the mod object: mod.content, mod.events,
mod.hooks, mod.world and mod.battle keep their names and are replaced by
portable services for api >= 3 mods only. The engine provides modApi = 3
(src/core/Version.lua) and refuses a manifest with a higher number.
A complete portable mod
{
"id": "portable_example",
"name": "Portable Example",
"version": "1.0.0",
"api": 3,
"games": ["gen1", "gen2", "gen3"],
"entry": "main.lua"
}
return function(mod)
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)
end
The entry chunk may use local mod = ... or return function(mod) ... end.
Content changes belong there, before the loader freezes the registries. The
runnable mods/examples/portable_api in the repository shows the pattern.
Declaring games
games takes gen1, gen2, gen3, rse, frlg, a version id or all
(Manifest). A manifest that says nothing
is Gen 1 only; gen2compat is not a Gen 3 claim. Claim a game only after
running on it.
Services behind the facade
API 3 routes the mod object through engine services. You never require them.
| Service | Facade | What it does |
|---|---|---|
UnifiedContent |
mod.content, mod.datasets |
canonical records for the core registries; native shapes for the rest |
UnifiedRecords |
records inside content, hooks and events | converts species, move, item, trainer and encounter rows between the Gen 1 shape and the native one |
UnifiedIds |
ids inside records | Gen 1 spellings for species (FARFETCHD, MR_MIME) and Gen 3 types (PSYCHIC, MYSTERY) |
UnifiedEffects |
effect on a move |
maps Gen 1 effect ids to the native effect; an effect with no verified mapping is refused |
UnifiedCallbacks |
mod.events, mod.hooks, mod:supports |
boundary conversion of record-valued payloads; the capability query |
UnifiedWorld |
mod.world |
the common world method set |
UnifiedBattle |
mod.battle |
snapshots and intents |
UnifiedScripts |
mod.world:queueScript |
portable script rows |
Shared records
The portable registries are pokemon, moves, items, trainers and
encounters. They keep register, override, patch, remove, get and
each, and add has. Reads and iteration return detached records; apply
changes through a write method.
- Species:
baseStats.hp,attack,defense,speed,special;level1Moves;learnset = { { level = 9, move = "TACKLE" } };evolutions = { { method = "LEVEL", level = 16, species = "CHARMELEON" } }; string species, move, item, type and growth-rate ids. - Gen 2
levelMovesandinto, and Gen 3 numeric tables, are translated to that shape. Writingspecialsets both later Special stats; optionalspecialAttackandspecialDefensecarry the split, and Gen 1 requires them equal. - Moves use Gen 1 effect ids such as
NO_ADDITIONAL_EFFECT. - Trainers:
parties = { { { species = "MEW", level = 5 } } }pluspartyNames. Encounter slots:{ species = "MEW", level = 5 }; a shared encounter write applies to every Gen 2 time of day. - Text keeps the Gen 1 control characters with symbolic placeholders such
as
{PLAYER}. - A new registration needs no Gen 3 storage index; the adapter allocates a free slot and checks explicit collisions.
patch keeps untouched fields; lists replace or use __append / __prepend;
mod.DELETE deletes. An operation the selected game cannot represent returns
nil, reason before anything is recorded. A malformed record raises a schema
error attributed to the mod, and a failed entry chunk rolls back its
contributions. The native extension on a record is read-only data for
diagnostics.
Registries whose Gen 2 or Gen 3 shape differs from Gen 1 and that have no
canonical adapter are written by api 3 mods as pass-through records in the
native shape of the running game, and mod:supports("content", name) says
per game whether the registry has a target. Where each registry lands on
each game, and the Gold and Gen 3 record differences, is in
Registry Fields; Gold serves 44 of the 47 registries,
Emerald 34 and FireRed and LeafGreen 33. A registry with no home is reported,
never silently merged.
Capability query
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. Every content handle also
has supports() and an available boolean. Content and world answers describe
implemented interfaces; game state may still refuse a call.
For hooks and events the answer is native per version: a name returns
true only on versions that have a call site. Names with a call site in every
generation (for example pokemon.level_visible, battle.field_residual) are
true on every game; Gen 1 only names (battle.style, catch.nickname,
catch.party_full) and Gen 1 and Gen 2 names (transition.style, world.tod,
map.palette) answer from their per-generation call sites; mod.options_changed
is true everywhere; Gen 2
additions follow the cart (phone.call_received, radio.channel,
phone.contact_list and the boot intro are Gold, Silver and 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. The tables
in Reference: Hooks and Reference: Events
show the call sites per generation.
hooks:wrap, events:on and events:once accept any catalogued name on any
game, so one source file loads everywhere; a subscription to a name the game
does not raise simply never fires. Unknown names raise. Check mod:supports
before relying on a callback.
Hooks and events
mod.hooks:wrap(name, function(next, ...) end) and mod.events:on / once
keep their forms. API 3 converts record-valued payloads at the boundary
(including the mutable pokemon.before_give). Priorities, unsubscribe closures,
owner rollback and the run-vanilla-at-most-once rule are unchanged. Game,
battle and context handles stay native. A mod may subscribe to another mod's
events but only emit under its own mod.<id>.* prefix; engine events cannot be
forged. The start-menu hook exposes executable onSelect rows on every
generation.
World services
Handles can be captured at entry time and resolve the current game when used. A
call with no active world or save returns nil, reason.
The method set: current, party, canReorderParty, reorderParty,
availableFieldActions, useFieldAction, canFly, flyTo, mapOverview,
warpTo, toggleObject, setFlag, getFlag, effectiveEncounters,
activeBlockAt, replaceBlock, spawnNpc, liveMaps, removeNpc, npc,
queueScript, startWildBattle, invalidateMap and overworld. Actions
validate arguments and native busy state before starting.
mod:<key>flags are private booleans in the mod's save bucket on every generation. Symbolic native flags need authoritative constants for the version; Crystal does not reuse Gold's mapping.party()returns detached Pokemon withnickname,stats.special, string ids, short statuses (SLP,PSN,BRN,FRZ,PAR) and moves as{ id, pp, maxPp }.activeBlockAt(mapId, x, y)returns a native block id plustileSize,cellSize,coordinateBaseandkind. Blocks are 32 px on Gen 1 and Gen 2 and 16 px on Gen 3, zero-based. It is never a collision byte.spawnNpc(mapId, objDef)takes a detachedmaps[].objectsshape and returns a session idmapId .. "_obj_" .. index;npc(mapId, id)resolves it. Handles expose movement, facing, placement, passability andsetAppearance(spriteId).removeNpcremoves only the calling mod's runtime NPCs. Runtime NPCs survive map reloads in a session but are not saved; respawn on load. A permanent NPC belongs in a map content patch.world.talkreceives the native game or overworld and the target; callnext(gameOrWorld, target)for the normal interaction (it runs at most once). On Gen 3scriptKeyruns the native script VM; Gen 1 talk scripts through this path are refused with an error.
Method signatures: Mod Object.
Portable scripts
queueScript(rows, { onDone = function(ok, reason) end }) validates the whole
list before running anything, waits for native completion between asynchronous
rows, reports completion once and cancels with a replaced session. Rows are
{ verb, arg... }, at most 1024. The verb tables (common verbs on all three
generations, adapted verbs per game, and the refusal messages) are in
Script commands. Native script
bytecode stays version-specific.
For single actions use warpTo(mapId, x, y, facing, options) or
startWildBattle(species, level, onDone); warp options (arrive, onDone,
keepMusic) are read only for api 3 mods and have generation limits. api 1
and 2 mods keep the legacy warpTo contract, which ignores opts.
Battle snapshots and intents
local snapshot = mod.battle:snapshot()
if snapshot and snapshot.prompt == "menu" then
mod.battle:submit({ id = 1, revision = snapshot.revision, kind = "menu", choice = "fight" })
end
Use a fresh, increasing intent id per mod; native prompt, stale-revision and
replay checks still apply. Snapshots have 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
through 3; targets is empty outside target selection. An item's catchChance
is a 0 to 100 percentage and reading it rolls nothing. Mutating a snapshot does
not change the battle. See Mod Object.
Screens on Gen 3
mod.ui.push(game, id, ...) pushes a screen registered in content.screens. The
Gen 3 bridge is what an api 3 mod gets from mod.ui; api 1 and 2 mods keep
the Screens-based push and the Gen 1 TextBox. Gen 3
has no builtin Gen 1 screens, so an unregistered id raises. A screen state
table (enter, update, draw, exit, optional isOpaque) 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 instead.
screen.pushed and screen.popped are raised. mod.ui.TextBox.new(game, text, onDone, opts) opens the native message window; opts.choice and opts.stay
raise an error on Gen 3, so use the ask or choice script rows for
questions.
What Gen 3 does and does not get
Gen 3 reuses the Gen 1 and Gen 2 hook, event and registry names and invents
none. It has no phone, radio, apricorns, Bug Contest, mail or Unown puzzle, so
those registries and events are never raised from Gen 3 code. Gen 3 registries
that have a native consumer route to their own tables (gen3Pokemon,
gen3Moves, gen3Items, gen3Encounters, gen3Text, and others); the
battle registries (move_effects, statuses, ai_classes, battle_anims)
hold mod content only, because the cart's own effects are battle-script code.
The registry-by-registry routing, with the Gold and Gen 3 subsections, is in
Registry Fields. The per-version call-site lists are in
Hooks and Events.
Testing a portable mod
luajit tests/run_modkit.lua
Run the same body against every claimed version and assert the loader result, native data changes, unsupported results, rollback and frozen content. Registering a callback only proves the registration contract; 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.
See also: Portable mods guide, Compatibility, Registries, Events and Hooks, Manifest.
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.