3
Concepts Data Model
bryanthaboi edited this page 2026-08-15 23:44:59 -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.

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, tokens and 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_classes and evolution_methods merge into gen2* 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, landmarks and radio_channels exist because Gold does. Writing one on a Gen 1 boot is a reported drop, not an error.
  • No Gen 2 home. field (including field.boot), text_pointers, map_scripts, rulesets, transitions and link_fields are Gen 1 surfaces: on Gold the write is taken, dropped and reported. Everything this page says about constants' seeded gameplay keys, field and text_pointers is therefore 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.

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.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)

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).