7 Cookbook UI And Infrastructure
bryanthaboi edited this page 2026-10-21 17:34:48 -04:00

Cookbook: UI and infrastructure

These recipes add screens, menu choices, saved values, or integrations. Read each prerequisite: creating a screen does not automatically open it.

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.

The mod object surface: Reference: The Mod Object; worked path: Tutorial 11.

R31 - Add a Start-menu entry

UI hooks. Before you paste: Also register QuestLog using R32 in the same mod. UI means user interface. Otherwise the new menu row has no screen to open.

Complete walkthrough / required concept.

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

mod.hooks:wrap("ui.start_menu.items", function(next, game, items)
  mod.ui.insertBefore(items, "OPTION", {
    label = "QUESTS",
    onSelect = function() mod.ui.push(game, "QuestLog") end,
  })
  return next(game, items)
end)

Checkpoint: the row appears with the mod on, is gone with it off. Anchor on stable labels; a missing anchor appends. The same pattern serves ui.party.submenu, ui.options.rows, and ui.pc.items.

Gen 2 / Gen 3: Gold and every Gen 3 game raise ui.start_menu.items; Gen 3 rows need a label and an onSelect. ui.pc.items has no Gen 3 call site. For mod.ui.push to reach the Gen 3 screen stack the mod must declare "api": 3.

R32 - Register a custom screen

UI callbacks. Before you paste: R31 provides the menu route that opens this screen. new creates it, draw displays it, and update handles input.

Complete walkthrough / required concept.

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

mod.content.screens:register("QuestLog", {
  new = function(game)
    local self = { isOpaque = true }
    function self:update(dt)
      if game.input:wasPressed("b") then game.stack:pop() end
    end
    function self:draw()
      mod.ui.Font.drawBox(0, 0, 20, 18)
      mod.ui.Font.draw("QUEST LOG", 16, 16)
    end
    return self
  end,
})

Checkpoint: mod.ui.push(game, "QuestLog") opens it, and the push_screen script command reaches it from any script row. A factory that throws is logged against the mod, never a dead end.

Gen 3: with "api": 3, the screen goes on the Gen 3 stack and draws into the 160x144 interior of the 240x160 frame, offset to (40, 8); set gbaCanvas = true on the screen to draw in the full frame. Gen 3 has no builtin Gen 1 screens, so an unregistered id raises. screen.pushed and screen.popped fire as usual.

R33 - Persist mod state

Events + saves. Before you paste: This counts entries into Pallet Town and stores the count for this mod in the current playthrough. Trigger it after a save is available, then explicitly save the game.

Complete walkthrough / required concept.

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

mod.events:on("map.entered", function(event)
  if event.mapId == "PALLET_TOWN" then
    mod.save:set("visits", mod.save:get("visits", 0) + 1)
    mod.log:info("Pallet visits: %d", mod.save:get("visits", 0))
  end
end)

Checkpoint: enter Pallet Town and read the visit count in the log. Save, close/reopen the game, and return to Pallet Town; the next count continues from the saved value. Loading directly into the map can itself trigger a map-entry event, so this is a count of entries, not unique player sessions.

The value belongs to this mod ID in the current save. A different mod does not read the same bucket by calling its own mod.save:get. Removing or changing the mod's ID has save consequences described in Save Model.

Gen 2 / Gen 3: map.entered fires on Gold and Gen 3 too, with that game's map id (FR_PALLET_TOWN on FireRed, for example).

R34 - Define a mod option

Basic Lua. Before you paste: The option is read at startup in this example. Change it, then restart before checking Tackle's power; a saved checkbox does not re-run content registration.

Complete walkthrough / required concept.

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

mod.options:define({
  { key = "hard_mode", type = "toggle", label = "HARD MODE", default = false },
})
if mod.options:get("hard_mode") then
  mod.content.moves:patch("TACKLE", { power = 50 })
end

Checkpoint: the toggle renders in the manager under your mod; changes persist in options.lua and fire mod.options_changed. Restart to apply this example's content patch; turning the option off takes effect at the next content load too. Row types: toggle, choice, number, text.

R35 - Export an inter-mod API

Advanced multi-mod example. Before you paste: These are two separate entry files, each with its own manifest. The consumer declares colorlib as an optional dependency; a required missing dependency would prevent that mod from loading.

Complete walkthrough / required concept.

Outline / alternative: read the requirements above; this is not a complete mod.

-- Complete colorlib/main.lua (its manifest id is "colorlib"):
return function(mod)
  mod.exports.label = function() return "CORAL" end
end
-- Complete consumer/main.lua, in a DIFFERENT mod folder:
-- Its manifest adds: "optional_dependencies": ["colorlib@^1.0.0"]
return function(mod)
  local color = mod.find("colorlib")
  if color then mod.log:info("Color: %s", color.exports.label()) end
end

These are two complete Lua files, not one file with two returns. Make one manifest per folder using Getting Started, then add the optional dependency field to the consumer's manifest. The JSON member above is a fragment; retain the enclosing object and comma separators.

Checkpoint: with both mods enabled, the consumer logs Color: CORAL. With colorlib absent, the optional consumer skips that call. If you instead use dependencies, a missing library prevents the consumer from loading. Use the version range rules when exporting a real API that can change across releases.

R37 - Reshape Oak's intro speech

UI hooks + saves. Before you paste: Start a new test save to run Oak's introduction. This adds questions/steps without replacing the entire intro screen.

Complete walkthrough / required concept.

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

mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
  steps = next(steps, speech)
  mod.ui.insertStepAfter(steps, "demo_mon", {
    id = "show_mew",
    kind = "say",
    pic = { type = "pokemon", id = "MEW" },
    reveal = "wipe",
    cry = "MEW",
    text = "This is MEW.\nKeep it quiet.",
  })
  mod.ui.insertStepBefore(steps, "ask_rival_name", {
    id = "snack_pick",
    kind = "choice",
    pic = "oak",
    saveKey = "snack",
    text = "Pick a snack.",
    choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
  })
  return steps
end)

mod.events:on("intro.oak_speech.answered", function(ev)
  if ev.saveKey then mod.save:set(ev.saveKey, ev.value) end
end)

Checkpoint: NEW GAME shows the MEW beat and the snack menu; the pick survives under save.modData[<id>].snack. Anchor on vanilla step ids (oak_welcome, demo_mon, name_player, ...; see Reference: Hooks). Pics take "oak" / "rival" / "player", a species id, or { type = "image", path = mod.path .. "/art.png" }. Worked example: mods/examples/example_silly_oak.

Gen 2 / Gen 3: Gold raises the same intro.oak_speech.build hook with matching step ids, but has no rival-naming beat, so ask_rival_name is missing and the snack step appends at the end. Gen 3 raises the intro.oak_speech.* events from its own new-game scene but has no build hook.

R45 - Bag wrap, page-jump, hold-to-scroll

UI hooks. Before you paste: Open the bag with enough entries to scroll. Wrapping goes from the last row to the first; page-jump moves a screenful; key repeat responds to holding a direction.

Complete walkthrough / required concept.

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

mod.hooks:wrap("ui.list_menu", function(next, opts, ctx)
  opts = next(opts, ctx) or opts
  if ctx.kind == "bag" then
    opts.wrap, opts.pageJump, opts.keyRepeat = true, true, true
  end
  return opts
end)

Checkpoint: the bag wraps top↔bottom, Left/Right jump a page, and holding Up/Down scrolls. Other lists (a different kind, which defaults to the list's title) stay vanilla.

Gen 2 / Gen 3: Gold raises ui.list_menu for its script menus only, and Gen 3 from its generic list menu; neither bag reports kind = "bag".

R46 - Digits on the naming screen

UI hooks. Before you paste: Open a naming screen. This replaces one character row; keep the confirmation and case-switch controls when adapting it.

Complete walkthrough / required concept.

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

mod.hooks:wrap("ui.naming.grid", function(next, grid, ctx)
  grid = next(grid, ctx)
  local out = {}
  for i, row in ipairs(grid) do out[i] = row end
  out[4] = { "0", "1", "2", "3", "4", "5", "6", "7", "8" }
  return out
end)

Checkpoint: NEW NAME can pick digits. Keep an "ED" cell and a single-cell case-switch row so confirm / case-flip still work.

Gen 2 / Gen 3: both raise ui.naming.grid with their own grid layout; inspect the rows next returns before replacing one.

R47 - Wider survey zoom

Hooks. Before you paste: Try the mapped zoom control or ZOOM option after loading. The hook changes the available range, not the current zoom directly.

Complete walkthrough / required concept.

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

mod.hooks:wrap("zoom.range", function(next, lo, hi, S)
  lo, hi = next(lo, hi, S)
  return lo - S, hi  -- another full fit-scale of survey-out
end)

Checkpoint: hotkey 4 / the ZOOM row reach sub-1 scales so more of the region fits on screen.

Gen 2: Gold shares the zoom module, so the hook applies there too.

R48 - Super Game Boy border

Advanced rendering + art. Before you paste: Put an original transparent border at assets/sgb_border.png. Its center must leave the gameplay view readable. SGB means Super Game Boy.

Complete walkthrough / required concept.

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

local border
mod.events:on("game.ready", function()
  border = love.graphics.newImage(mod.assets:path("assets/sgb_border.png"))
end)
mod.hooks:wrap("render.letterbox", function(next, ctx)
  next(ctx)
  if not border or ctx.worldActive then return end
  love.graphics.setColor(1, 1, 1, 1)
  love.graphics.draw(border, 0, 0, 0,
    ctx.ww / border:getWidth(), ctx.wh / border:getHeight())
end)

Checkpoint: menus / battles show your border in the letterbox; survey zoom (worldActive) skips it so the world fill is undisturbed. For post-process approaches see render_pipelines.

Gen 2 / Gen 3: Gold and Gen 3 raise render.letterbox with the same ww, wh and worldActive keys.

R54 - React to real-world steps

Platform integration. Before you paste: This requires supported iOS/Android step access and the manifest permission shown below. Desktop availability is false. It counts physical walking, not in-game movement.

Complete walkthrough / required concept.

On iOS and Android the engine counts the player's real steps natively. Use a manifest that declares the steps permission, then probe whether the platform supports it. The player sees this permission in the manager.

Complete manifest.json for this recipe:

{
  "id": "walking_bonus",
  "name": "Walking Bonus",
  "version": "1.0.0",
  "entry": "main.lua",
  "api": 2,
  "games": ["red"],
  "permissions": ["steps"]
}

Place the callbacks below inside the entry function in main.lua. On a supported phone, the operating system may ask for permission; desktop keeps the example inactive.

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

mod.events:on("game.ready", function(ev)
  if mod.steps:available() then
    mod.steps:sync()  -- async; the OS consent sheet appears on first use
  end
end)

mod.events:on("map.entered", function()
  local walk = mod.steps:poll()   -- { steps, from, to } or nil
  if walk then
    local total = mod.save:get("walked", 0) + walk.steps
    mod.save:set("walked", total)
    mod.log:info("%d real steps banked", total)
  end
end)

Checkpoint: on desktop available() is false and the mod stays dormant; on a phone, walking between polls delivers { steps, from, to } once, to every permissioned mod, never twice. Without the permission, sync and poll raise an error naming it (src/mods/Steps.lua).