Table of contents
- Cookbook: world and scripting
- R20 - Add a script command
- R21 - Add a text token
- R22 - Add a talk script to an NPC
- R23 - Spawn a new NPC on a map
- R24 - Add an onStep trigger
- R25 - Add a new map + connection
- R40 - Running shoes
- R41 - Day / night palette
- R42 - Slower / faster warp fades
- R51 - Make an ordinary NPC battleable
- R52 - One-button field actions
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.
Start here
Playing
Tutorials
- Choose a tutorial
- 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
Creative guides
Concepts
Recipes and reference
Author and developer tools
Home · Start Playing · Glossary · Troubleshooting · Repository · Community
Game data is imported locally from a supported ROM supplied by the player. Shared mods contain original work or transform recipes, never extracted game content. Check each guide's target game before using its examples.