4
Cookbook UI And Infrastructure
bryanthaboi edited this page 2026-08-15 23:44:59 -04:00

Cookbook — UI and infrastructure

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

R31 — Add a Start-menu entry

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.

R32 — Register a custom screen

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.

R33 — Persist mod state

mod.save:set("visits", (mod.save:get("visits", 0)) + 1)

Checkpoint: the value survives save/load under save.modData[<id>] — your namespace alone, quarantine-proof and removed cleanly when the mod is gone (Save Model).

R34 — Define a mod option

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. Row types: toggle, choice, number, text.

R35 — Export an inter-mod API

-- in colorlib/main.lua
mod.exports.tint = function(hex) ... end

-- in a dependent mod (manifest: "dependencies": ["colorlib@^1.0"])
local color = mod.find("colorlib")
if color then color.exports.tint("#ff0044") end

Checkpoint: the dependent loads after colorlib and calls its export; with colorlib absent, find returns nil and the branch skips. Range-check color.version with require("src.mods.Semver") (a supported require) before relying on a shape.

R37 — Reshape Oak's intro speech

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.

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

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 (kind unset) stay vanilla.

R46 — Digits on the naming screen

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.

R47 — Wider survey zoom

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.

R48 — Super Game Boy border

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.

R54 - React to real-world steps

On iOS and Android the engine counts the player's real steps natively. Declare the steps permission in manifest.json (the player sees it in the mod manager), then probe, sync and poll:

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).