4
Cookbook World And Scripting
bryanthaboi edited this page 2026-08-15 23:44:59 -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

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.