6 Cookbook World And Scripting
bryanthaboi edited this page 2026-10-20 17:01:40 -04:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.