Table of contents
The mod object
Every member of the mod table the loader hands your entry function, as
constructed in src/mods/Loader.lua (_api). Nothing outside this
surface, the registries, the cataloged events/hooks, and the documented
data schemas is supported.
The same table is built for every game (Red, Blue, Yellow, Gold, Silver,
Crystal, FireRed, LeafGreen, Ruby, Sapphire, Emerald). A manifest with
"api": 3 replaces mod.content, mod.events, mod.hooks, mod.world,
mod.battle and mod.datasets with the portable services and adds
mod:supports; those differences are marked API 3 below and explained in
Portable API 3. Everything else is identical at every
api level.
Your chunks run inside a per-mod sandbox (src/mods/Sandbox.lua): _G is
your own table, io / os / love.filesystem / love.system /
love.event answer through compat stand-ins (src/mods/LegacyCompat.lua)
that log a migration warning per call, and love.thread, ffi and
debug are refused outright. The members below are the sanctioned
replacements.
Start from the task
| You want to | Use |
|---|---|
| Add or change imported game content | Content |
| React when gameplay happens or alter a calculation | Events and hooks |
| Save quest progress or player preferences | Persistence |
| Keep an independent record or checkpoint | Durable storage and checkpoints |
| Read files bundled with your mod | Assets and files |
| Add a menu, screen, or widget | UI toolkit |
| Work with a running map or NPC | World facade |
| Read a running battle or submit a menu choice | Battle facade |
| Read a user-supplied file, an asset pack or another imported game | Assets and files, Imported datasets |
| Fetch data or run background work | Background work |
The signatures below use parentheses for arguments and -> for returned
values. A | B means either value may be returned. A value such as
{ id, version } is a Lua table with named fields. Check the section's timing
note before calling a facade: content is entry-file work, and mod.world
requires the live game after game.ready.
Identity
| Member | Value |
|---|---|
mod.id |
the manifest id |
mod.version |
the manifest version string |
mod.path |
the mod's directory ("mods/") |
mod.manifest |
a deep copy of the validated manifest - mutating it changes nothing |
mod.generation |
1, 2 or 3: the generation of the running game |
mod.developer |
true when the engine was started in developer mode (POKEPORT_DEV=1); plain data, so a mod can register diagnostics without reading the environment |
mod.game |
the live game service owner, resolved per generation (the Gen 1 singleton, Gold's Game2 instance, the Gen 3 shell); read on every touch, nil before it exists. Hold this instead of requiring src.core.Game |
mod.DELETE |
the tombstone sentinel for patch (patch(id, { field = mod.DELETE })) |
mod:supports |
API 3 only. (surface, name) -> ok, reason; surfaces "content", "world", "hooks", "events" |
Content
| Member | Signature | Notes |
|---|---|---|
mod.content.<registry>:register |
(id, value) |
add; duplicate id errors |
mod.content.<registry>:override |
(id, value) |
replace whole record |
mod.content.<registry>:patch |
(id, partial) |
deep-merge |
mod.content.<registry>:remove |
(id) |
tombstone |
mod.content.<registry>:get |
(id) -> value |
merged view, base data included |
mod.content.<registry>:each |
() -> iterator |
merged view |
All 47 registries plus the v1 aliases (scripts, ui) hang off
mod.content; see Reference: Registries. A registry
with no home on the running game takes the write, drops it and reports it once
per mod. Content verbs are entry-chunk-only (Lifecycle).
API 3: mod.content is the portable facade. The registries pokemon,
moves, items, trainers and encounters take the canonical Gen 1 record
shape on every game and add has(id); reads and iteration return detached
records, and an operation the game cannot represent returns nil, reason
before anything is recorded. Every other registry keeps its native shape. Each
content handle also has supports() and an available boolean.
Imported datasets
| Member | Signature | Notes |
|---|---|---|
mod.datasets:open |
(version) -> view | nil, reason |
read-only view of another imported game: view.content.<registry> with get / has / each (detached, schema-checked, ids in lexical order), view.assets:path(rel) / info(rel) for assets/generated/..., plus view.version and view.generation. reason is "unknown_version", "not_imported" or "invalid_cache". Never changes the active game |
Events and hooks
| Member | Signature |
|---|---|
mod.events:on |
(name, callback, priority) -> unsubscribe |
mod.events:once |
(name, callback, priority) -> unsubscribe |
mod.events:emit |
(name, payload) - only "mod.<id>.*" names |
mod.hooks:wrap |
(name, callback, priority) -> unsubscribe |
Catalogs: Reference: Events, Reference: Hooks. Both list which games raise each name.
API 3: the same four verbs accept any catalogued name on any game (a name
the game never raises just never fires) and raise on an unknown name. Record
valued payloads (species, moves, items, parties, the mutable
pokemon.before_give) are converted to the portable shape at the boundary;
game, battle and context handles stay native. A mod may subscribe to another
mod's events but only emit under its own prefix.
Persistence
| Member | Signature | Backed by |
|---|---|---|
mod.save:get |
(key, default) -> value |
save.modData[modId] |
mod.save:set |
(key, value) |
save.modData[modId] |
mod.options:define |
(rows) |
rows render in the manager |
mod.options:get |
(key) -> value |
options.modOptions[modId], falling back to the row default |
mod.migrations:add |
(since, fn) |
runs on load for saves older than since |
Row shapes and migration ordering: Save Model.
Durable storage and checkpoints
Independent of mod.save (which rides the normal SAVE), mod.storage
writes per-mod, per-playthrough records through the engine's persistence
backend, and mod.checkpoints captures/restores engine-owned runtime
state. Implementations: src/mods/Storage.lua, src/core/Checkpoint.lua.
| Member | Signature | Notes |
|---|---|---|
mod.storage:context |
(game) -> { engineVersion, gameVersion, playthroughId } |
identity is opaque; no slot or path ever crosses |
mod.storage:write |
(game, key, value) -> ok, code, message |
data-only tables; staged and decode-verified |
mod.storage:read |
(game, key) -> value, code, message |
recovers from a valid staged/backup generation |
mod.storage:writeBytes |
(game, key, bytes) -> ok, code, message |
opaque byte strings (NULs included), 512 MiB per key, staged and byte-verified; never decoded or executed |
mod.storage:readBytes |
(game, key) -> bytes, code, message |
the byte counterpart of read |
mod.storage:list |
(game, prefix) -> keys, code, message |
keys are slash-separated [A-Za-z0-9_-] segments |
mod.storage:delete |
(game, key) -> deleted, code, message |
table and byte values share one key space; delete before switching a key's type |
mod.storage:selected |
(game) -> facade | nil, code, message |
title screen only: a bound facade over the launcher-selected playthrough, same methods minus the game argument; non-allocating |
mod.checkpoints:inspect |
(game) -> capability |
capability.canCapture before capturing |
mod.checkpoints:capture |
(game) -> checkpoint, code, message |
data-only; store it through mod.storage |
mod.checkpoints:restore |
(game, checkpoint) -> ok, code, message |
validates before mutation, verifies by recapture, rolls back on failure; emits checkpoint.restored on success |
mod.checkpoints:resume |
(game, checkpoint) -> ok, code, message |
title-session counterpart of restore; rebuilds a usable title session on failure |
mod.checkpoints:ensureNormalSave |
(game, checkpoint) -> anchored, code, message |
make a never-saved playthrough title-bootable exactly once; true, "already_exists" after that |
The playthrough identity is allocated lazily on the first
storage/checkpoint call, so an unused API changes no save bytes. A
checkpoint's identity.engineVersion is compatibility metadata, not
runtime state: a checkpoint captured by an older engine release restores
on a newer one (src/core/Checkpoint.lua,
normalizeVerificationMetadata). Full contracts and error codes: the
Durable tool storage section of docs/modding.md and RFCs 0003-0006.
Assets and files
| Member | Signature | Notes |
|---|---|---|
mod.assets:path |
(relative) -> "mods/<id>/<relative>" |
the path helper every sprite field wants |
mod.assets:image |
(relative) -> Image |
cached love.graphics.newImage; needs a graphics context |
mod.assets:list / mod.assets:info |
(relative) |
same as mod:list / mod:info |
mod.assets.<registry> |
- | v1 alias of mod.content.<registry> |
mod:read |
(relative) -> contents, err |
read a file from the mod directory |
mod:list |
(relative) -> names |
sorted directory listing inside the mod directory |
mod:info |
(relative) -> { type, size } | nil |
type is "file" or "directory"; nil when absent |
mod.imports:info |
(id) -> { id, name, file, size, md5, required } | nil, err |
a validated required_imports / optional_imports entry; undeclared ids are refused |
mod.imports:read |
(id, offset, length) -> bytes | nil, err |
zero-based range read, 8 MiB per call; no host path or handle |
mod.packs:list |
() -> { { importer, pack, version, kind, required, installed }, ... } |
every pack you declared, installed or not |
mod.packs:info |
(importer, pack) -> { importer, pack, kind, version, entries, source } |
declared packs only |
mod.packs:entries / entry |
(importer, pack[, entryId]) |
entry metadata (id, file, size, width, height, frames, ...) |
mod.packs:read |
(importer, pack, entryId) -> bytes | nil, err |
8 MiB cap; refuses a size mismatch |
mod.packs:metadata |
(importer, pack, entryId) |
the entry's importer-written metadata |
mod.cache:write / read / info / exists / delete |
(relative[, bytes]) |
installation-scoped generated data under mod_cache/<id>/; 64 MiB per write; not rewound by checkpoints, not scoped to a game or playthrough |
All path arguments join to your own directory: .., absolute paths and
drive letters are refused (src/mods/SafePath.lua). Validated
required_imports land under baseroms/ and are read the same way
(Manifest).
Scripting sugar
| Member | Signature | Notes |
|---|---|---|
mod.commands:register |
(verb, fn) |
sugar over mod.content.commands; replacing an engine verb must say override on the registry |
UI toolkit
mod.ui is the shared widget facade (src/ui/ModUI.lua, picked per manifest
by ModUI.forApi(api)); widgets load on first touch. An api 1 or 2 mod gets
the Screens-based push and the Gen 1 TextBox on every game. An api 3 mod
gets the Gen 3 bridge (src/ui/game3/mod_ui.lua) when the running game is a Gen 3 game.
| Member | What it is |
|---|---|
mod.ui.Menu, mod.ui.ListMenu, mod.ui.ChoiceBox, mod.ui.QuantityBox, mod.ui.NamingScreen, mod.ui.PicBox, mod.ui.TextBox, mod.ui.Font, mod.ui.Theme |
the stable widget set for building screens |
mod.ui.push(game, screenId, ...) |
instantiate through the screens registry and push. On Gen 3 (api 3 only) a screen state table (enter, update, draw, exit, optional isOpaque) draws into the 160x144 interior of the 240x160 frame (translated to 40, 8); set gbaCanvas = true to draw the full frame. An unregistered id raises, because Gen 3 has no builtin Gen 1 screens |
mod.ui.insertBefore(items, anchorLabel, item) |
anchored menu insertion (missing anchor appends) |
mod.ui.insertAfter(items, anchorLabel, item) |
same, after the anchor |
mod.ui.removeLabel(items, label) |
remove a row by label |
mod.ui.PokemonIcon.draw(game, summary, x, y, opts) |
draw a party icon from the detached { species, hp, maxHp } summary; opts.selected / opts.counter request the native selected animation. Composes with icons content and the pokemon.icon hook; invalid summaries return false, code, message |
mod.ui.insertStepBefore(steps, anchorId, step) |
Oak-speech step insertion (intro.oak_speech.build) |
mod.ui.insertStepAfter(steps, anchorId, step) |
same, after the anchor id |
mod.ui.removeStep(steps, id) |
drop a step by id |
On Gen 3 mod.ui.TextBox.new(game, text, onDone, opts) opens the native
message window; opts.choice and opts.stay raise an error there, so use the
ask or choice script rows for questions. screen.pushed and screen.popped
are raised for Gen 3 mod screens too.
World facade
mod.world materializes on first touch, once the live Game exists
(after game.ready); nil before that and in headless runs without a
game. Implementation per generation: src/world/WorldAPI.lua (Gen 1),
src/world/gen2/WorldAPI.lua (Gold, Silver, Crystal) and
src/world/game3/WorldAPI.lua (Gen 3). The method set is the same where the
game has the feature; the table notes the exceptions.
API 3: mod.world is the portable service (src/mods/UnifiedWorld.lua).
It can be captured at entry time and resolves the live game when used; with no
active world or save a call returns nil, reason. Its method set is exactly:
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, adapted to every generation (including Fly and
startWildBattle on Gold, which the native Gold facade lacks).
| Member | Signature | Notes |
|---|---|---|
mod.world:current |
() -> { mapId, x, y, facing } |
the player's position |
mod.world:warpTo |
(mapId, x, y, facing, opts) |
scripted warp. api 1 and 2 keep the legacy contract: opts is accepted and nothing in it is read (no arrive FX, no onDone, no keepMusic). api 3 reads opts.arrive = "fly" | "teleport" (arrival FX), opts.onDone and opts.keepMusic, with generation limits |
mod.world:toggleObject |
(mapId, objName, visible) |
persisted; emits world.object_toggled |
mod.world:setFlag / getFlag |
(name, value) / (name) -> bool |
the shared story-flag namespace; a real change emits flag.changed. A "mod:<key>" name is a private boolean in the mod's save bucket on every generation |
mod.world:replaceBlock |
(bx, by, block) |
current map, block grid; on Gen 3 the block is a 16 px metatile id (0..1023) |
mod.world:activeBlockAt |
(mapId, x, y) -> blockId, { tileSize, cellSize, coordinateBase, kind } |
the native block id at a cell, never a collision byte. Blocks are 32 px on Gen 1 and Gen 2 and 16 px (metatiles) on Gen 3, zero-based. Native on Gen 1 and Gen 3; Gold gets it through API 3 |
mod.world:liveMaps |
() -> { { mapId, ox, oy, active }, ... } |
the active map plus every connected map being drawn (ox/oy are pixel offsets). Bracketed by world.live_maps_preparing / world.live_maps_updated on Gen 1 and Gen 2 |
mod.world:effectiveEncounters |
(mapId, terrain[, opts]) -> { chance, dist } |
the wild distribution after encounter.table, with no RNG and no side effects; terrain is "grass", "water" (Gen 1 also "indoor"); Gold takes { daytime = "NITE" } to preview a time of day. Reflects a Gold swarm but not a roaming legendary |
mod.world:canFly / flyTo |
() -> bool / (mapId) -> true | nil, reason |
FLY needs a destination picker; flyTo accepts only a visited Fly town. Native on Gen 1 and Gen 3; Gold gets them through API 3 |
mod.world:overworld |
() -> overworld | nil |
the live overworld object for the running game; engine-owned, version specific |
mod.world:spawnNpc |
(mapId, objDef) -> npcId |
runtime NPC, same shape as maps[].objects; returns the object id, allocated at spawn time; not serialized - respawn on map.entered |
mod.world:removeNpc |
(npcId) |
the id spawnNpc returned; refuses a mapped object and another mod's |
mod.world:npc |
(mapId, indexOrName) -> handle |
active map only (and ghosts on a live neighbor map); accepts def.index, def.name or the object id. handle.id is the object id. Handle methods below |
mod.world:queueScript |
(rows, extra) |
run command rows through the script queue. API 3 validates the whole list first, accepts the portable verbs on every game, and reports completion once through { onDone = fn(ok, reason) } (Portable scripts) |
mod.world:invalidateMap |
(mapId) |
drop the built map so edits show |
mod.world:startWildBattle |
(species, level[, onDone]) -> true | nil, reason |
push a wild battle; refuses mid-warp, mid-battle, and an unhealthy party. Native on Gen 1 and Gen 3; Gold gets it through API 3 |
mod.world:mapOverview |
() -> overview |
read-only snapshot: collision rows (cell grid), optional tileRows (2x) and tileDetailRows (4x) of GB shades "0"-"3" with matching width/height fields, and markers of { kind, x, y } for warp / visible item / untaken hidden (src/world/MapOverview.lua); same contract on Gold and Gen 3 |
mod.world:canReorderParty |
() -> bool |
true only during idle overworld play with 2+ mons |
mod.world:reorderParty |
(fromSlot, toSlot) -> true | nil, reason |
one-based slots; menus, movement, scripts, battles and transitions refuse |
mod.world:availableFieldActions |
() -> { { id, label, rods? }, ... } |
the field items and moves that can start right now (bicycle, fish, cut, surf, strength, flash, dig, teleport; Gold adds headbutt, whirlpool, waterfall, sweet_scent, squirtbottle; Red adds softboiled with sources and targets; Gen 3 offers bicycle, cut, surf, strength, flash, rock_smash, waterfall, dig, teleport, sweet_scent, plus softboiled and milk_drink with sources); empty while the world is busy. Render what you understand and ignore unknown ids |
mod.world:useFieldAction |
(id, opts) -> true | nil, reason |
perform a listed action through the game's own field-item path; fishing takes opts.rod and auto-picks a sole rod. Stale or busy requests change nothing |
NPC handles
scriptMove, marchInPlace, face and position drive an NPC through the
overworld's scripted-movement list, which locks player input while it walks. The
other methods drive an actor without that lockout (a networked ghost, an ambient
walker). All of them work the same on Gen 1, Gen 2 and Gen 3.
| Method | Signature | Notes |
|---|---|---|
handle:scriptMove |
(dir, tiles, onDone) |
queued scripted walk; gates player input until it finishes |
handle:marchInPlace |
(dir?) |
step animation in place |
handle:face |
(dir) |
turn |
handle:position |
() -> x, y |
current cell |
handle:stepNow |
(dir) |
one tile now, with no queue; deliberately does not check collision (a replayed, already-decided move must not be re-judged) |
handle:canStep |
(dir) -> bool |
the map's opinion; ask first when you want collision |
handle:placeAt |
(x, y, facing) |
snap with no animation and clear any step in flight |
handle:isMoving |
() -> bool |
pace a driver instead of stomping a move in flight |
handle:setPassable |
(flag) |
the flag collision skips; a passable object still draws and can be talked to |
handle:setAppearance |
(spriteId) |
swap to another registered sprite without touching identity, cell, step or passability |
An object spawned at runtime carries no TEXT_* id, so the vanilla talk path
has nothing to say for it; answer the A press with the
world.talk hook. On Gold and Gen 3, spawnNpc takes
(mapId, objectDefinition) and returns the generated id (for example
CHERRYGROVE_CITY_obj_2), which mod.world:npc(mapId, id) resolves; the later
world.interacted payload carries the live object in ev.target, so read
ev.target.id.
Battle facade
mod.battle is the read-only snapshot and menu-intent surface. It materializes
on first touch like mod.world and is nil with no live game. Per generation:
src/battle/BattleAPI.lua, src/battle/gen2/BattleAPI.lua,
src/battle/game3/BattleAPI.lua; on API 3 it is the portable service
(src/mods/UnifiedBattle.lua).
| Member | Signature | Notes |
|---|---|---|
mod.battle:snapshot |
() -> snapshot | nil, reason |
detached copy: player, enemy, party, moves, items, revision, prompt, turn, catchable; on API 3 also double, active, battlers, targets. Mutating it changes nothing. An item's catchChance is a 0 to 100 percentage; reading it rolls nothing |
mod.battle:submit |
(intent) -> true | nil, reason |
apply a validated menu choice to the snapshot you just read. Every intent needs a strictly increasing positive integer id (per mod) and the latest revision; stale, replayed, covered or invalid intents change nothing |
Intents: { kind = "menu", choice = "fight" } (party, item, run),
{ kind = "move", slot = 1..4 } and { kind = "back" } on every game. Gen 1
also has { kind = "safari", action = "ball" } (bait, rock, run) and
{ kind = "mimic", index = 1 }. Singles use battler ids 0 and 1; Gen 3 doubles
use 0 through 3. Tutorial, link, forced, stale and covered battle states refuse
core intents. Use mod.input for ordinary text advance.
Scripted input
Source-safe GB button presses through the engine's multi-source input bookkeeping: releasing a mod's hold can never drop a button the keyboard, a pad, the touch overlay or another mod still owns.
| Member | Signature | Notes |
|---|---|---|
mod.input:tap |
(game, btn) |
queue exactly one wasPressed edge for the next fixed step; holds nothing |
mod.input:press |
(game, btn) -> token |
hold until released |
mod.input:release |
(token) -> bool |
idempotent; refuses tokens taken by another mod. Outstanding tokens release on rollback, hot reload and input recovery |
Buttons are up, down, left, right, a, b, start, select.
Needs the live game (game.ready).
Device and platform
| Member | Signature | Notes |
|---|---|---|
mod.device:powerInfo |
() -> state, percent |
read-only battery state; state is LÖVE's "unknown" / "battery" / "nobattery" / "charging" / "charged", percent is 0-100 or nil. The rest of love.system stays sandboxed |
mod.datetime:date / time / dateTime |
(game, timestamp) -> string |
format a timestamp under the player's DATE FORMAT / TIME FORMAT options; invalid timestamps return "----" |
mod.steps:available |
() -> bool |
false on builds without the native step bridge and for mods without the steps permission, so a probe is always safe |
mod.steps:sync |
() -> bool |
ask iOS/Android to refresh its step count (async; OS consent sheet on first use) |
mod.steps:poll |
() -> { steps, from, to } | nil |
the next delivery for this mod; each permissioned mod gets its own copy, and the same walk is never delivered twice |
sync and poll without the steps permission raise an error naming it
(src/mods/Steps.lua).
Background work
These facades exist because love.thread is refused (it needs the compute
permission): the workers run engine code, so a mod gets asynchrony without a
Lua state the sandbox cannot reach. poll never blocks; call it from a hook or update.
| Member | Signature | Notes |
|---|---|---|
mod.fetch:available |
() -> bool |
false without a transport or the network permission |
mod.fetch:get |
(url, opts) -> handle | nil, reason |
http/https only, on the URL and every redirect; opts.accept, opts.maxSeconds (max 30); four in flight per mod |
mod.fetch:poll |
(handle) -> { status, body, err, progress } |
status: "pending", "ok", "error", "cancelled" |
mod.fetch:release / cancel |
(handle) |
free a finished job / drop an unwanted result |
mod.job:available |
() -> bool |
false without threads or the background permission |
mod.job:run |
(script, arg, opts) -> handle | nil, reason |
script is a file in your mod dir, run in your sandbox with no mod object and no require; plain data in, plain data out; opts.maxSeconds (default 5, max 30); two per mod, four per machine |
mod.job:poll |
(handle) -> { status, result, err } |
same statuses as fetch |
mod.job:release / cancel |
(handle) |
cancel drops the result; it cannot stop the thread, so write jobs that terminate |
mod.postLog |
(body, opts) -> handle |
one-way crash-log report to the https:// log_url the manifest declares; needs the network permission and log_url, never returns the response body. Same handle/poll/release shape as mod.fetch |
The full rules (User-Agent attribution, handle ownership, budget
semantics) are in the Background HTTP and Background jobs sections of
docs/modding.md.
Inter-mod API
| Member | Signature | Notes |
|---|---|---|
mod.exports |
a table you fill (or assign) | what other mods see |
mod.find |
(otherId) -> { id, version, exports } | nil |
nil when absent, disabled, failed, or not yet loaded |
Logging
| Member | Signature |
|---|---|
mod.log:info / warn / error |
(fmt, ...) - attributed [mod-id] lines |
Supported requires
Mod code may require exactly three engine modules without the
engine_internals permission: src.mods.Semver (range-check another
mod's exports), src.audio.ChipAsm (author chip music and sfx), and
src.pokemon.Stats (Stats.isShiny / Stats.calc for indicator mods).
Everything else under src. is unsupported-and-may-break; under
POKEPORT_DEV=1 an undeclared require logs a warning naming the mod and
module.
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.