Table of contents
- The data model
- Read a record before writing one
- camelCase discipline
- A species record
- A move, an item, a trainer
- The rules as data: constants and field
- constants → Data.constants
- field → Data.field
- field.boot - the new-game config
- text_pointers → Data.text_pointers
- The same registries on Gold and Gen 3
- Map objects and object ids
The data model
The ground-truth record shapes mods write, the one naming rule that prevents the classic silent failure, and the two deep registries that hold the rules the engine used to hard-code.
Read a record before writing one
A record is a Lua table of named fields. Its id is the internal name
the engine and other records use to find it; its displayed name is what a
player sees. For example, "PIKACHU" is an id, baseStats is a field, and
{ attack = 70 } is a small table used as that field's value.
| You are changing | Start with | Reason |
|---|---|---|
| A field on an existing game record | patch(id, { field = value }) |
retains the other fields |
| A new record | register(id, { ... }) |
needs every required field |
| A field's allowed values | Reference: Registries | it is the generated schema source of truth |
Registry changes belong in the mod entry file and take effect when content merges; see Lifecycle. This page uses Gen 1 shapes first. The Gold and Gen 3 differences are collected in the comparison below instead of being a requirement for a Red/Blue/Yellow mod.
camelCase discipline
Base-data record fields are camelCase, exactly as the importer emits
them (src/import/RomExtractor.lua): baseStats, catchRate,
spriteFront, frontSize, growthRate, dexEntry. The only snake_case
a modder meets is manifest keys (optional_dependencies, game_version -
JSON convention) and registry names (map_scripts, type_chart). Never a
record field.
The canonical mistake is base_stats:
-- WRONG: the engine never reads base_stats; the stat change silently no-ops
mod.content.pokemon:patch("PIKACHU", { base_stats = { speed = 90 } })
-- RIGHT
mod.content.pokemon:patch("PIKACHU", { baseStats = { speed = 90 } })
Under api = 2 the wrong form is a load error with the suggestion
did you mean "baseStats"? - the schema normalizes case and underscores
to catch exactly this class of typo (src/mods/Schemas.lua).
A species record
Every required field, from the pokemon schema:
mod.content.pokemon:register("MODMON", {
id = "MODMON", name = "MODMON", dex = 152,
types = { "NORMAL" },
baseStats = { hp = 60, attack = 70, defense = 55, speed = 90, special = 65 },
catchRate = 120, baseExp = 100,
growthRate = "MEDIUM_FAST",
level1Moves = { "TACKLE" },
learnset = { { level = 12, move = "QUICK_ATTACK" } },
evolutions = {},
spriteFront = mod.assets:path("front.png"),
spriteBack = mod.assets:path("back.png"),
frontSize = 5,
})
Optional: index, tmhm, dexEntry
({ kind, heightFt, heightIn, weight, text, text2?, heightM?, weightKg? }),
icon, cry, palette, trueColor, battleTheme, battleScaleFront,
battleScaleBack. Id-typed fields (types, growthRate, learnset[].move,
evolutions[].species, ...) are resolved after the merge; a dangling
reference is a load error naming the field path.
A move, an item, a trainer
mod.content.moves:patch("BLIZZARD", { accuracy = 70 })
mod.content.items:patch("POTION", { price = 100 })
mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })
Field-by-field shapes for all 47 registries:
Reference: Registries. Notable move fields:
category ("physical" | "special" | "status") overrides the Gen 1
type-based split; effect names a move_effects record; priority,
highCrit, multiHit, fixedDamage are all data, not code.
The rules as data: constants and field
Two deep registries hold the values that used to be Kanto/Red literals
scattered through the engine. The id is a top-level key of the target
table; register and patch merge into that key, override replaces it
outright, lists append (__prepend reaches the front), and keys the
catalog does not describe are accepted and merged unchanged, so a mod may
stash its own data beside the engine's.
constants → Data.constants
mod.content.constants:patch("levelCap", 80)
mod.content.constants:patch("bagSize", 40)
mod.content.constants:patch("dexSize", 251)
Seeded keys: bagSize, partyMax, boxCount, boxSize, moveMax,
levelCap, coinCap, moneyCap, dexSize, dexDigits, hmMoves,
encounterBuckets, badges. Each holds the number the engine hard-coded
(bag 20, party 6, 4 moves, ...), so a mod-free boot behaves exactly as
before - the faithful Game Boy limits are now the defaults, overridable
in one sanctioned place.
dexSize is the upper bound the Pokedex counts to and dexDigits the
zero-padded width dex numbers print with; the engine derives both at data
load from the highest dex across the merged species (151 and 3 for
vanilla), so a mod that adds species usually needs neither. Set them
explicitly for a sparse or oversized dex.
badges is the ordered badge set - list position is the badge number the
trainer card draws:
mod.content.items:register("NINTH_BADGE",
{ id = "NINTH_BADGE", name = "NINTH BADGE", price = 0 })
mod.content.constants:patch("badges", { { id = "NINTH_BADGE" } }) -- appends
Each entry is { id, name?, icon?, item? }; id must resolve to an
items record. override("badges", { ... }) replaces the whole set - the
total-conversion verb.
field → Data.field
The overworld's data grab bag: ledges, hidden items, badge gates, fly network, town map. Per-map dictionaries merge per map, so adding content never touches Kanto's:
mod.content.field:patch("hiddenItems", {
SABLE_COVE = { { x = 3, y = 9, item = "NUGGET" } },
})
mod.content.field:patch("flyOrder", { "SABLE_COVE" }) -- appends
mod.content.field:patch("townMap", {
locations = { SABLE_COVE = { x = 4, y = 17, name = "SABLE COVE" } },
})
field.boot - the new-game config
The one key a total conversion always writes. Read when a new game starts:
mod.content.field:patch("boot", {
startMap = "SABLE_COVE", startX = 3, startY = 4, startFacing = "down",
playerName = "ALEX", rivalName = "JESS",
startMoney = 5000,
namePresets = { player = { "ALEX", "SAM" }, rival = { "JESS", "KIT" } },
})
| key | meaning |
|---|---|
startMap / startX / startY / startFacing |
where the new save spawns |
playerName / rivalName |
default names before the naming screens |
startMoney |
starting money (vanilla 3000) |
lastHeal |
blackout return point; defaults to the spawn |
namePresets |
{ player = {...}, rival = {...} } naming-screen menus |
screens |
{ splash, title, newGame } boot-screen ids, resolved through the screens registry (src/core/Game.lua bootScreens) |
starterScript |
a map_scripts entry to run in place of the Oak speech |
title |
title-screen presentation (logo, versionRibbon / version, player, ...); overrides the extracted title art keys |
Every key falls back to its vanilla value, so a partial patch - a new
spawn and nothing else - keeps the Red names, money, and boot screens.
screens resolves through the screens registry at boot
(src/core/Game.lua) and title is read by the Gen 1 title screen
(src/ui/TitleState.lua); starterScript is declared and merged but not
yet read by the boot chain - drive an intro through screens.newGame instead
(Tutorial 12).
text_pointers → Data.text_pointers
The third deep registry: per-map TEXT-constant bindings. Every key is a map label carrying the same shape, so one sign binding never restates the map:
mod.content.text_pointers:patch("PalletTown", {
TEXT_PALLETTOWN_SIGN = { text = "_MySign" },
})
Entry fields: text, label, asm, mart (a list of item ids - how
shop stock is data), nurse, pc, cableClub.
The same registries on Gold and Gen 3
Choose your target game
| Your mod targets | What to read first |
|---|---|
| Red, Blue, or Yellow | the main record shape on this page |
| Gold, Silver, or Crystal | the On Gold (Gen 2) subsection for the registry you use |
| FireRed, LeafGreen, Ruby, Sapphire, or Emerald | the On Gen 3 subsection for the registry you use |
| more than one | the shared registry name plus each generation's record shape, or an api: 3 mod (Portable API 3) |
The registry name stays the same across generations. The target table or the record fields may differ, and a few registries have no destination in one generation. The following detail tells you which case applies.
Registry names are one vocabulary across all three generations; what changes
per generation is where a write lands and, for some registries, the record
shape (src/mods/Schemas.lua, the Schemas.GEN1 / GEN2 / GEN3 routing
tables). Of the 47 registries, 44 are available on Gold and 34 on Emerald
(33 on FireRed and LeafGreen). The cases:
- Shared target.
commands,strings,tokens,type_chart,growth_rates,screens,render_pipelinesand the audio registries (music,sfx,cries,map_songs) keep one Data path in every game.pokemon,moves,items,font,battle_sprite_scales,transitionsand the deprecatedaudioare shared on Gold only;balls,evolution_methods,icons,item_effects,mapsandspritesare shared on Gen 3 only. - Routed on Gold.
maps,tilesets,sprites,text,encounters,trainers,palettes,icons,battle_anims,constants,statuses,move_effects,item_effects,balls,ai_classes,evolution_methodsandmap_scriptsmerge intogen2*namespaces (data.gen2Maps,data.gen2Statuses,data.gen2Scripts, ...) that Gold's own systems read. The registry name a mod uses never changes; only the table underneath it does. - Routed on Gen 3.
pokemon,moves,items,trainers,encounters,text,rom_text,map_scripts,move_effects,statuses,ai_classes,battle_anims,palettes,font,landmarksanddecorations(Ruby, Sapphire, Emerald) merge intogen3*tables. - Gen 2 only.
held_items,phone_contacts,apricornsandradio_channelsexist because Gold does.decorationsandlandmarksalso exist on Gen 3 (decorationson Hoenn only), androm_texton Gold and Gen 3. Writing one on a boot with no home is a reported drop, not an error. - No Gen 2 home.
rulesets,text_pointersandlink_fields: on Gold the write is taken, dropped and reported. - No Gen 3 home.
apricorns,audio,battle_sprite_scales,constants,field,held_items,link_fields,phone_contacts,radio_channels,rulesets,text_pointers,tilesetsandtransitions. Gen 3 has no phone, radio or apricorns, so none of those exist there.
field on Gold is a closed deep registry that reads only boot (spawn,
starting money, rival name, name presets); every other Gen 1 key
(ledges, hiddenItems, badgeGates, townMap, flyOrder, playerPics)
has no Gold consumer. Everything this page says about constants' seeded
gameplay keys and text_pointers is Gen 1 material. On Gold, constants
addresses data.gen2Constants, whose ids are the ROM's ordered name lists
(speciesOrder, itemOrder, ...), and a write there replaces the key
rather than deep-merging into it, because list position is the id a script
byte resolves through. Gold's encounters ids are a closed set of lookup
kinds, not map names.
link_fields is a low-level cart protocol surface. It does not make a loose
enabled mod eligible for player link play; use launcher link setup with either
a vanilla cart or a sealed custom cart.
A routed registry can also be generation-shaped: the same register /
patch call validates against the booting generation's record schema, so a
wrong-shape record fails loudly instead of merging fields nothing reads. The
species record is the sharpest case. On Gold, baseStats splits special
into specialAttack / specialDefense, the level-up table is levelMoves
(level 1 rows live there; there is no separate level1Moves), the pic size
is picSize, an evolution names into rather than species (with optional
time and comparison arms), and the record carries the breeding block
(eggGroups, eggMoves, eggSteps, genderRatio) and the two wild held
items that Gen 1 has no analogue for. On Gen 3 the record keeps the split
stats and learnset, drops level1Moves and the Gen 1 frontSize, and adds
abilities, friendship, eggCycles, eggGroups, itemCommon / itemRare
and a numeric index of up to 1023.
-- Gen 1 boot
mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })
-- Gold boot: same registry, Gen 2 shape
mod.content.pokemon:patch("TOTODILE", { baseStats = { specialAttack = 80 } })
An api: 3 mod writes the Gen 1 shape for pokemon, moves, items,
trainers and encounters and the engine translates it to the booted game;
Gen 2 and Gen 3 numeric tables are translated back on read. Registries with a
Gold or Gen 3 shape and no canonical adapter (move_effects, statuses,
ai_classes, battle_anims, palettes, sprites, balls, ...) are written
by api: 3 mods as pass-through records in the native shape; mod:supports
reports unsupported only where the game has no target for the registry.
Trainer records take pic and trueColor on both shapes, and species
trueColor is honored by Gold's battle screen, so a full-color custom
portrait renders the same way in either game. Gen 3 art is always full color,
so trueColor is accepted and changes nothing there. Field-by-field Gen 2 and
Gen 3 shapes live in Reference: Registries; the
migration story is
Preparing Your Mod for Gen 2.
Map objects and object ids
A map's objects list is data like every other record - the shape the
importer emits, and the shape a maps patch
appends to. One field carries the identity, index:
| Kind | Stable across saves? | Safe as your saved key? | Where you get it |
|---|---|---|---|
| Mapped object | yes | yes | its map id and objects[].index |
Runtime object from spawnNpc |
no | no | the id returned for this session |
| Gen 1 script object number | not the cross-generation name | no | the map object's index |
| Gold script object number | not the cross-generation name | no | index + 1 in a Gen 2 script |
Use the mapped object's "<mapId>_obj_<index>" id for per-object data in
mod.save. The detailed rules below explain why runtime and script numbers do
not replace that stable id.
{ index = 1, x = 5, y = 2, sprite = "SPRITE_AGATHA", movement = "STAY",
range = "DOWN", text = "TEXT_AGATHASROOM_AGATHA",
trainerClass = "OPP_AGATHA", trainerParty = 1 }
The engine composes "<mapId>_obj_<index>" from the map id and that
index, and everything that has to name one object uses that string:
npc.idon the live instance (src/world/NPC.lua; Gold builds the same string insrc/world/gen2/Npc.lua).- the key of the NPC instance pool, which is what lets one instance be a
neighbor-map ghost and then the real NPC once that map is entered
(
src/world/OverworldController.lua). - the save key for the per-object state the vanilla game keeps:
save.defeatedTrainers[npc.id]andsave.itemsTaken[npc.id].
That third use is why the id is stable across sessions and not merely
unique within one run: a trainer that un-beat itself on reload would be a
save bug. Treat mapId .. "_obj_" .. index as a contract. There is no
separate uid field because this is the uid - a mod that has mapId and
objDef.index can build it, and every surface below hands it over
already built.
Reading it from a mod
| Surface | Where the id is |
|---|---|
mod.world:npc(mapId, indexOrName) |
handle.id; the lookup also accepts the id itself |
world.npc_spawned |
ev.npcId |
world.interacted |
ev.target.id, when ev.kind == "npc" |
world.trainer_engaged |
ev.npc.id |
world.boulder_moved |
ev.npcId |
trainer.before_battle |
context.npcId |
Per-object state of your own belongs under that key in mod.save, which
is your namespace alone, rather than in the shared flag namespace
(Save Model):
mod.events:on("world.interacted", function(ev)
if ev.kind ~= "npc" then return end
local seen = mod.save:get("talkedTo", {})
seen[ev.target.id] = (seen[ev.target.id] or 0) + 1
mod.save:set("talkedTo", seen)
end)
On Gen 3, mod.world:spawnNpc returns the same mapId .. "_obj_" .. index
session id under api: 3, and npc(mapId, id) resolves it; the same
runtime-object rules below apply.
Three rules
- Never reuse a vanilla
index. The id is a save key, so an object that borrows a vanilla index inherits that object's defeated-trainer or item-taken state. Append with a high unused index (Tutorial 06), and if your mod expects company, claim a range rather than the next free number. - A runtime object gets an id, not a stable one.
mod.world:spawnNpcassignsmax(index) + 1at spawn time (OverworldState:addRuntimeObject), so the id depends on what else has spawned, and runtime objects are never serialized. Fine as a handle for the rest of the session; never as a save key. Anything that must survive a save belongs in map data. - Gen 2 scripts address objects by
index + 1. Gold keeps the same<mapId>_obj_<index>id on the NPC, but its script VM numbers objects the way the cart does - object 0 is the player - so an extracted object'sobjectIdisdef.index + 1(src/world/gen2/WorldAPI.lua). The id string is the cross-generation name;objectIdis a Gen 2 script argument. Gold'smod.world:npcalso looks up bydef.indexordef.nameonly, not by the id string, and its handle carriesobjectIdinstead ofid(Preparing Your Mod for Gen 2).
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.