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
Cookbook — world and scripting
Command vocabulary: Reference: Script Commands; worked paths: Tutorials 06–08.
R20 — Add a script command
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.
R21 — Add a text token
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.
R22 — Add a talk script to an NPC
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.
R23 — Spawn a new NPC on a map
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 serialized — 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).
R24 — Add an onStep trigger
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.
Contributions chain first-truthy-return-consumes
(src/script/MapScripts.lua).
R25 — Add a new map + connection
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.
R40 — Running shoes
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.
R41 — Day / night palette
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).
R42 — Slower / faster warp fades
Warp fades already live in the transitions registry (warp_fade,
white_flash). Retiming them is a patch, not a new hook:
mod.content.transitions:patch("warp_fade", { frames = 30 }) -- slower
Checkpoint: door warps linger longer. Battle wipe styles use the same
registry via transition.style.
R51 — Make an ordinary NPC battleable
A new object that is a trainer takes the vanilla path — two data records, no hooks:
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:
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.
R52 - One-button field actions
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:
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.
Start here
Concepts
Tutorials
- The ladder
- 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
Reference
Guides
- Art Pipeline
- Audio Authoring
- Preparing Your Mod for Gen 2
- Translations
- Total Conversions
- Link Compatibility
- Publishing a Mod
- Custom Carts
- Docs Style Guide
Playing
Engine 1.0.0 · mod API 2 · repository · Discord
This project ships no ROM data. All game content is decoded on the player's machine from their own verified Pokemon Red ROM; mods ship recipes and original assets, never extracted content.