Table of Contents
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.
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 }), icon, cry, palette,
trueColor. 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 37 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 |
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); starterScript and title are 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
Registry names are one vocabulary across both generations; what changes per
generation is where a write lands and, for some registries, the record shape
(src/mods/Schemas.lua, the Schemas.GEN2 / Schemas.GEN1 routing tables).
Four cases:
- Shared target.
pokemon,moves,items,type_chart,commands,screens,strings,font,tokensand the audio registries keep one Data path in both games and work on Gold as-is. - Routed. On a Gold boot,
maps,tilesets,sprites,text,encounters,trainers,palettes,icons,battle_anims,constants,statuses,move_effects,item_effects,balls,ai_classesandevolution_methodsmerge intogen2*namespaces (data.gen2Maps,data.gen2Statuses, ...) that Gold's own systems read. The registry name a mod uses never changes; only the table underneath it does. - Gen 2 only.
held_items,phone_contacts,decorations,apricorns,landmarksandradio_channelsexist because Gold does. Writing one on a Gen 1 boot is a reported drop, not an error. - No Gen 2 home.
field(includingfield.boot),text_pointers,map_scripts,rulesets,transitionsandlink_fieldsare Gen 1 surfaces: on Gold the write is taken, dropped and reported. Everything this page says aboutconstants' seeded gameplay keys,fieldandtext_pointersis therefore Gen 1 material. On Gold,constantsaddressesdata.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.
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.
-- 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 } })
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. Field-by-field Gen 2 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:
{ 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)
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
Concepts
Tutorials
- The ladder
- 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
Reference
Guides
- Art Pipeline
- Audio Authoring
- Preparing Your Mod for Gen 2
- Translations
- Total Conversions
- Link Compatibility
- Publishing a Mod
- Custom Carts
- Docs Style Guide
Playing
Engine 1.0.0 · mod API 2 · repository · Discord
This project ships no ROM data. All game content is decoded on the player's machine from their own verified Pokemon Red ROM; mods ship recipes and original assets, never extracted content.