Skip to content

Cookbook World And Scripting

bryanthaboi edited this page Oct 20, 2026 · 6 revisions

Cookbook: world and scripting

Scripts are lists of commands; callbacks are Lua functions. A map has several coordinate units, explained in Tutorial07.

Setup: a working mod from Getting Started. Examples target Red/local single-player unless a recipe explicitly says otherwise. Use Modding Basics for syntax and Glossary for unfamiliar terms. Save and restart after content edits. A validation pass and an in-game checkpoint are different checks.

Where code goes: insert Lua fragments inside your existing return function(mod) ... end in main.lua. A complete-file label or named other file overrides that default. Do not paste a second entry function inside the first. Read the recipe's requirements before copying its code. For output, use Read log output; for IDs, use Find internal IDs.

Command vocabulary: Reference: Script Commands; worked paths: Tutorials 06–08.

R20 - Add a script command

Scripting. Before you paste: After registering the command, call { "mod_reward", 500 } from a talk script in the same mod. Registering the command alone does not give money.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

mod.commands:register("mod_reward", function(ctx, amount)
  ctx.save.money = math.min(999999, (ctx.save.money or 0) + (amount or 100))
end)

Checkpoint: { "mod_reward", 500 } works in any script row. The handler is fn(ctx, ...); ctx.save, ctx.overworld, ctx.game are the surface; return a label string to jump. The script.command hook sees every dispatched row if you need to intercept instead.

Gen 2 / Gen 3: both script VMs dispatch the same registry: Gold through its MOD_COMMAND op, Gen 3 for any op it does not know. On Gen 3 ctx.save is a view over the live save, and ctx.save.money reads and writes the real money. Native script bytecode stays per game.

R21 - Add a text token

Functions + text. Before you paste: After registering the token, put {CLOCK} in a text entry you can read. A token substitutes a value into a message.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

mod.content.tokens:register("CLOCK", function(game, arg)
  return tostring(math.floor((game.save.playSeconds or 0) / 3600))
end)

Checkpoint: {CLOCK} in any text renders the value. Grammar is {NAME} or {NAME:arg}, handler fn(game, arg) -> string | nil (src/script/Tokens.lua); nil drops the token, a thrown error is logged and drops it.

Gen 2 / Gen 3: tokens expand in Gold and Gen 3 text too. game is that game's own object, so game.save.playSeconds is a Red field.

R22 - Add a talk script to an NPC

Scripting. Before you paste: A Red save in Pallet Town. Talk to the existing girl after restarting; the internal TEXT name identifies her conversation.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

mod.content.map_scripts:register("PALLET_TOWN", {
  talk = {
    TEXT_PALLETTOWN_GIRL = {
      { "face_player" },
      { "show_text", "My line is\nmodded now!" },
    },
  },
})

Checkpoint: the Pallet Town girl says your line; every other TEXT constant on the map is untouched. Per TEXT constant the highest-precedence contribution wins; false suppresses lower ones.

Gen 2 / Gen 3: map_scripts there hold native scripts (gen2Scripts, gen3Scripts), so this row list does not run, and an api 3 mod gets unsupported. For one talk script on every game, wrap the world.talk hook and queue portable rows with mod.world:queueScript (Portable scripts).

R23 - Spawn a new NPC on a map

Events. Before you paste: An NPC is a non-player character. The map-entry callback creates this one while the game runs. Leave and re-enter Pallet Town to trigger it.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

mod.events:on("map.entered", function(ev)
  if ev.mapId == "PALLET_TOWN" then
    mod.world:spawnNpc("PALLET_TOWN", {
      index = 95, x = 10, y = 9, sprite = "SPRITE_FISHER",
      movement = "WALK", range = "ANY_DIR", text = "TEXT_PALLETTOWN_GIRL",
    })
  end
end)

Checkpoint: the NPC appears and wanders on every visit. Runtime objects are not written into the save; re-spawn on map.entered. For a permanent NPC, patch the map record's objects with __append instead (Tutorial 06). spawnNpc returns the object id it allocated, which is good for the rest of the session and never as a save key (Map objects and object ids).

Gen 2 / Gen 3: mod.world:spawnNpc exists on every game with the same object shape; use that game's map ids and sprite ids, or a registered sprites record. removeNpc removes only your own runtime NPCs.

R24 - Add an onStep trigger

Callbacks. Before you paste: Know the map's player-cell coordinates. This writes a log when the player steps onto cell (10,10); use the log instructions linked above.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

mod.content.map_scripts:register("PALLET_TOWN", {
  onStep = function(game, ow, x, y)
    if x == 10 and y == 10 then
      mod.log:info("stepped on the spot")
      -- return true to consume the step
    end
  end,
})

Checkpoint: stepping on (10,10) logs; the map's own onStep still runs. Returning true stops the later step handlers. This example only logs, so the map's next handler can still run (src/script/MapScripts.lua).

Gen 2 / Gen 3: onStep is a Gen 1 map_scripts field. The world.stepped event fires on all three with mapId, x and y; listen to it instead when the mod must run everywhere (it cannot consume the step).

R25 - Add a new map + connection

Map authoring. Before you paste: A Red map uses blocks for its width/height; the player moves in smaller cells. Use Tutorial07's grid and checked starting scene before adapting this small map.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

local W, H = 6, 5
local blocks = {}
for i = 1, W * H do blocks[i] = 10 end
mod.content.maps:register("MODROUTE", {
  id = "MODROUTE", label = "ModRoute", index = 1000,
  tileset = "OVERWORLD", width = W, height = H, blocks = blocks,
  borderBlock = 10,
  connections = { west = { map = "PALLET_TOWN", offset = 0 } },
})
mod.content.maps:patch("PALLET_TOWN", {
  connections = { east = { map = "MODROUTE", offset = 0 } },
})

Checkpoint: walking off Pallet Town's east edge enters MODROUTE and back. Both directions are needed; blocks must count width * height. Full treatment: Tutorial 07.

Gen 2 / Gen 3: Gold's maps are its own records (gen2Maps). Gen 3 maps are 16px metatile layouts; tilesets has no Gen 3 target. An api 3 mod writes maps as a pass-through record in the native shape on each game.

R40 - Running shoes

Hooks. Before you paste: On foot, hold the mapped B button. The hook receives frames per step: a smaller number makes a step finish sooner.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

mod.hooks:wrap("movement.speed", function(next, frames, ctx)
  frames = next(frames, ctx)
  if not ctx.onBike and ctx.input and ctx.input:isDown("b") then
    return math.max(1, math.floor(frames / 2))
  end
  return frames
end)

Checkpoint: hold B on foot to move at bike cadence; the bicycle itself is unchanged. ctx.surfing is also available if you want surf dash.

Gen 2 / Gen 3: the hook runs on both, and the Hoenn bike honours it. FireRed, LeafGreen, Ruby, Sapphire and Emerald already have running shoes; their ctx adds running and underwater.

R41 - Day / night palette

Hooks + palettes. Before you paste: Use a map whose selected palette is ROUTE. This demo alternates after 512 steps; it does not follow the real-world clock.

Complete walkthrough / required concept.

Insert in main.lua inside the entry function (unless a filename is shown).

mod.hooks:wrap("world.tod", function(next, tod, ctx)
  -- 512 steps ≈ a short day cycle for demos
  local period = math.floor((ctx.steps or 0) / 512) % 2
  return period == 0 and "DAY" or "NIGHT"
end)

mod.hooks:wrap("map.palette", function(next, name, map, ctx)
  name = next(name, map, ctx)
  if ctx and ctx.tod == "NIGHT" and name == "ROUTE" then
    return "CAVE"  -- stand-in night tint
  end
  return name
end)

mod.events:on("world.tod_changed", function(ev)
  mod.log:info("time of day -> %s", ev.tod)
end)

Checkpoint: after enough steps the overworld palette flips and world.tod_changed fires. Heavier lighting / rain overlays belong on render.zones or a render_pipelines present pass (Rendering pipelines).

Gen 2 / Gen 3: Gold raises both hooks from its real clock, and map.palette there takes daytime ids (MORN, DAY, NITE, ...), not Red's palette names. Gen 3 raises neither.

R42 - Slower / faster warp fades

Basic Lua. Before you paste: Use a door warp to see the longer fade. frames is a duration in game frames; a larger number lasts longer.

Complete walkthrough / required concept.

Warp fades already live in the transitions registry (warp_fade, white_flash). Retiming them is a patch, not a new hook:

Insert in main.lua inside the entry function (unless a filename is shown).

mod.content.transitions:patch("warp_fade", { frames = 30 })  -- slower

Checkpoint: door warps linger longer. Battle wipe styles use the same registry via transition.style.

Gen 3: transitions has no Gen 3 target; the write is dropped and reported.

R51 - Make an ordinary NPC battleable

Advanced scripting. Before you paste: Choose one of the two routes below. The existing-NPC route also needs the trainer record from the first code block, but not its new-map-object patch.

Complete walkthrough / required concept.

A new object that is a trainer takes the vanilla path: two data records, no hooks:

Insert in main.lua inside the entry function (unless a filename is shown).

mod.content.trainers:register("OPP_MOD_FISHER", {
  id = "OPP_MOD_FISHER", name = "FISHER", baseMoney = 30,
  parties = { { { species = "MAGIKARP", level = 10 } } },
})
mod.content.maps:patch("ROUTE_1", {
  objects = { __append = {
    { index = 96, x = 5, y = 20, sprite = "SPRITE_FISHER",
      movement = "STAY", range = "DOWN", text = "TEXT_ROUTE1_YOUNGSTER1",
      trainerClass = "OPP_MOD_FISHER", trainerParty = 1 },
  } },
})

Checkpoint: talking to the object opens a trainer battle, and winning writes save.defeatedTrainers["ROUTE_1_obj_96"], the object id, which is also the key your own per-NPC state belongs under. Adding pic = mod.assets:path("art/fisher.png") to the trainer record gives that one NPC its own battle art (BattleState.trainerPicPath); one trainer record per NPC is what unique art costs.

Two things the data route does not get. Sight-line engagement reads a range out of the extracted trainer headers, keyed by map label and object index (Data:trainerHeader), and no registry writes those, so a mod trainer has range 0 and fights when talked to, never on sight. The challenge and after-battle lines come from the same headers, so the challenge falls back to the object's own text and a beaten mod trainer just repeats its ordinary line.

Making a vanilla NPC battleable is the other route. A map's objects is a replace-wholesale list, so no patch edits one entry in place; drive the fight from that NPC's talk script instead, which composes with other mods and is already per-NPC, because vanilla TEXT constants are:

Insert in main.lua inside the entry function (unless a filename is shown).

mod.commands:register("mod_check_beaten", function(ctx)
  local beaten = mod.save:get("beaten", {})
  ctx.lastCheck = ctx.npc ~= nil and beaten[ctx.npc.id] == true
end)
mod.commands:register("mod_mark_beaten", function(ctx)
  local beaten = mod.save:get("beaten", {})
  beaten[ctx.npc.id] = true
  mod.save:set("beaten", beaten)
end)
mod.content.map_scripts:register("ROUTE_1", {
  talk = {
    TEXT_ROUTE1_YOUNGSTER2 = {
      { "mod_check_beaten" },
      { "jump_if_true", "done" },
      { "face_player" },
      { "start_battle", "trainer", "OPP_MOD_FISHER", 1 },
      { "check_battle_result", "win" },
      { "jump_if_false", "end" },
      { "mod_mark_beaten" },
      { "label", "done" },
      { "show_text", "TEXT_ROUTE1_YOUNGSTER2" },
    },
  },
})

Checkpoint: Route 1's second youngster fights once, then goes back to their vanilla line. ctx.npc is the object the player talked to (OverworldState:showMapText passes it into the runner), so one handler shared by many NPCs still keys state per NPC. The two routes do not stack: a talk script wins over every other branch in OverworldState:talkTo, including the trainer branch, and an object carrying one is skipped by checkTrainerSight. Pick one per object.

Gen 2 / Gen 3: both routes are Gen 1 only: they rely on Red's trainer headers, maps objects and row-list talk scripts.

R52 - One-button field actions

Hooks + world API. Before you paste: Use the mapped SELECT action near appropriate terrain with the required item/move/badge. If no action is available, nothing happens.

Complete walkthrough / required concept.

mod.world:availableFieldActions() lists the field items and moves that can start at the player's current position, already filtered by item, move, badge, terrain and engine state; useFieldAction runs one through the active game's own field-item path. No generation-specific bike, collision or fishing logic in the mod:

Insert in main.lua inside the entry function (unless a filename is shown).

mod.hooks:wrap("input.step", function(next, game, dt)
  next(game, dt)
  if not game.input:wasPressed("select") then return end
  local world = mod.world
  if not world then return end
  local actions = world:availableFieldActions()
  if #actions == 0 then return end
  local ok, reason = world:useFieldAction(actions[1].id)
  if not ok then mod.log:info("field action refused: %s", reason) end
end)

Checkpoint: SELECT by water with a rod casts, SELECT on a bikeable map mounts (the row's label is "BIKE OFF" when riding). Fishing rows carry rods, so a picker can offer { rod = "SUPER_ROD" } explicitly. The list is empty while the world is busy, and unknown ids should be ignored: Gold adds headbutt, whirlpool, waterfall, sweet_scent and squirtbottle to the shared set, and the set can grow again.

Gen 3: availableFieldActions and useFieldAction are backed on every Gen 3 game, and input.step has a Gen 3 call site, so this recipe runs unchanged there.

Clone this wiki locally