5 Concepts Data Model
bryanthaboi edited this page 2026-10-20 17:01:40 -04:00

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_pipelines and the audio registries (music, sfx, cries, map_songs) keep one Data path in every game. pokemon, moves, items, font, battle_sprite_scales, transitions and the deprecated audio are shared on Gold only; balls, evolution_methods, icons, item_effects, maps and sprites are 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_methods and map_scripts merge into gen2* 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, landmarks and decorations (Ruby, Sapphire, Emerald) merge into gen3* tables.
  • Gen 2 only. held_items, phone_contacts, apricorns and radio_channels exist because Gold does. decorations and landmarks also exist on Gen 3 (decorations on Hoenn only), and rom_text on 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_pointers and link_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, tilesets and transitions. 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.id on the live instance (src/world/NPC.lua; Gold builds the same string in src/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] and save.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:spawnNpc assigns max(index) + 1 at 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's objectId is def.index + 1 (src/world/gen2/WorldAPI.lua). The id string is the cross-generation name; objectId is a Gen 2 script argument. Gold's mod.world:npc also looks up by def.index or def.name only, not by the id string, and its handle carries objectId instead of id (Preparing Your Mod for Gen 2).