Files
gen1recomp/docs/rfcs/0007-per-category-game-speed.md
T
david 3c71afb9fa Per-category GAME SPEED: overworld/battle/menu + core.logic_speed hook (RFC 0007)
GameSpeed is a single fast-forward multiplier applied uniformly to the
whole logic clock -- overworld walking, menu navigation and battle turns
all scale together. A player who wants 4X battles but 1X overworld (so a
cutscene or NPC dialogue doesn't blur past) has no way to get both.

Splits save.options.speed into speedOverworld/speedBattle/speedMenu, each
cycling independently, with an automatic migration so an existing save's
speed choice carries over. Game.speedCategoryInStack resolves which
category is active by walking the state stack (the same idiom
wideBattleInStack/fillScaleInStack already use), so a menu opened mid-
battle inherits battle speed rather than resetting to whatever "menu"
defaults to. Adds a new core.logic_speed hook so a mod can read or
override the resolved multiplier for the current frame regardless of
which category produced it, sitting after the link-play and run-argument
overrides so neither is a seam a mod can defeat.

RFC 0007 status: Proposed.
2026-08-09 20:13:21 -07:00

11 KiB

RFC 0007 — Per-category GAME SPEED and the core.logic_speed hook

Status

Proposed. Engine: GameSpeed.lua, Game.lua, BattleState.lua, OptionsMenu.lua, SaveData.lua, LauncherSettings.lua. Tests: tests/engine/game_speed_categories_test.lua, tests/engine/gate_hooks.lua (structural, automatic), tests/run_tests.lua (OptionsMenu row walk), tests/mod_ui_tests.lua (row id/order).

Motivation

GameSpeed (src/core/GameSpeed.lua) is a single fast-forward multiplier applied uniformly to the whole logic clock in Game:logicSpeed() / Game:update() -- overworld walking, menu navigation and battle turns all scale together. A player who wants 4X battles (grinding, a long gym fight) but 1X overworld (so a scripted cutscene or NPC dialogue doesn't blur past) has no way to get both; the one GAME SPEED row is a single ladder that applies everywhere at once.

This needs to be an engine change, not a mod: there is no per-frame seam a mod can use to swap the multiplier mid-step, and no public event granular enough to say "which category is active" (screen.pushed/screen.popped and battle.started/battle.ended are the closest and are not enough -- see Decisions below). The engine's own speed resolution has to become category-aware.

A category-aware speed resolution is also the general seam a platform-launcher integration or automation tool needs to read or override the effective multiplier for a given frame without caring which category produced it -- this RFC's core.logic_speed hook is written for that case alongside the player-facing Options rows.

The decision it extends

No prior D-number. Extends GameSpeed.lua's multiplier ladder (unchanged) with per-category resolution.

The exact API delta

Backward-compatible except for one save-data field rename, which ships with an automatic migration (see below) -- nothing in the public mod API (hooks, events, registries, mod.*) is renamed or removed.

save.options: speed -> speedOverworld / speedBattle / speedMenu

GameSpeed.CATEGORIES = { "overworld", "battle", "menu" } is the new list of categories, and GameSpeed.optionKey(category) maps a category to its save.options field name ("overworld" -> "speedOverworld", etc.). GameSpeed.LEVELS, .DEFAULT, .levelLabel, .clamp and .cycle are unchanged -- the ladder and its behavior are exactly what they were, just applied three times instead of once.

SaveData.defaultOptions() drops speed = 1 and adds speedOverworld = 1, speedBattle = 1, speedMenu = 1. SaveData.mergeOptions() migrates: a loaded options table that still has speed and none of the three new fields seeds all three from it, so an existing player's fast-forward preference carries over instead of two of the three categories silently resetting to 1X. speed is dropped on the way out (not carried forward), so a re-save never re-triggers the migration.

Game.speedCategoryInStack(stack)

New static helper, (stack) -> "battle" | "overworld" | "menu". Walks the whole state stack top-down -- the same idiom Game.wideBattleInStack and Game.fillScaleInStack already use -- looking for state.isBattle (new marker, BattleState.isBattle = true, covering every battle: wild, trainer, link, safari, the old-man demo) or state.isOverworld (existing marker, OverworldController's OverworldState.isOverworld = true). The first match wins; a state with neither marker (a menu, a text box, a naming screen, a cutscene) is transparent to the walk and falls through to whatever is under it. Nothing in the stack matching either falls back to "menu".

Game:logicSpeed() / Game:_resolveLogicSpeed()

Game:_resolveLogicSpeed() is new: it resolves Game.speedCategoryInStack against the live stack, maps the category to its save.options key via GameSpeed.optionKey, and returns GameSpeed.clamp of that option (or GameSpeed.DEFAULT). This is the exact category-resolution logic the new hook wraps.

Game:logicSpeed() keeps its existing early returns -- link play forces 1, a run-argument speed override wins over the saved option -- unchanged, and in the same order, before ever calling the hook. Only once neither applies does it call the core.logic_speed hook.

Game:_cycleSpeed(dir)

The keyboard hotkey and the gamepad shoulders/triggers that used to cycle the single speed option now cycle whichever category Game.speedCategoryInStack says is active: pressing the hotkey during a battle speeds up just the battle, on the overworld just the walk, in a menu just the menu. This is the natural per-category answer for a control that used to have one option to reach and now has three -- see Decisions below for why this reading was chosen over, say, always cycling overworld.

core.logic_speed

New hook, (game) -> number through the public wrapper signature (next, game), called once per Game:logicSpeed() (i.e. once per frame). Vanilla behavior (used when no mod claims the hook) is Game:_resolveLogicSpeed() -- exactly the category resolution above, nothing else. A subscriber may call next(game) and return its result to pass the vanilla multiplier through, or return a different number outright to override it for that frame (e.g. a bot mod forcing 1 during one route segment regardless of what category or option is active).

This intentionally sits after the link and speed-override checks in Game:logicSpeed(), not around them: link play staying locked to 1X "no matter what either player set this to" is exactly the invariant that would break if a mod's hook could override it, and the run-argument override exists so a bot/screenshot run's speed does not depend on a mod any more than on the player's saved option. Both stay unconditional early returns a mod never sees.

Not guarded by Runtime.wantsHook: Hooks:call already fast-paths to a bare vanilla(...) call when no mod has wrapped the name, and this hook fires every frame regardless.

Decisions on the issue's open questions

1. Overlays on top of another category's state (a party menu, a choice box, a naming screen opened mid-battle or mid-overworld). Resolved by making the category a property of stack position, not of the overlay's own type: an overlay with no isBattle/isOverworld marker is transparent to Game.speedCategoryInStack's walk and inherits whatever is under it. A party swap opened mid-battle reads as "battle"; a bag opened while walking reads as "overworld". This was chosen over giving every UI state its own fixed category (which would make a fast-forwarded battle visibly stutter back to 1X every time its party menu opens) because it matches what the player is actually doing moment to moment, and it reuses a pattern the codebase already leans on for exactly this "menus opened over X should behave like X" class of problem (Game.fillScaleInStack, Game.wideBattleInStack).

2. Cutscenes/scripts. No fourth category. A scripted sequence runs through the owning state's own machinery -- the overworld's script runner or a battle's message queue -- rather than pushing a state of its own, so it is already covered by decision 1: it inherits whatever category the state driving it resolves to. A cutscene state that genuinely has nothing under it (a pre-game intro) falls to "menu", the default for anything that is not battle or overworld gameplay -- consistent with those being pre-game presentation, not something a player is likely to want scaled differently from menu navigation.

3. Category granularity (splitting "menu" further). Deferred. Start with the three named here; GameSpeed.CATEGORIES and GameSpeed.optionKey are written so adding a fourth later (a Pokédex/Bag category, say) is one entry plus one new save.options field, not a resolution-logic rewrite. No current request motivates it.

4. The GAME SPEED hotkey/shoulder buttons, once "the" speed is three things. Game:_cycleSpeed now cycles whichever category is currently active (Game.speedCategoryInStack), rather than, say, always cycling overworld or requiring a modifier key to pick a category. A single physical control that means "speed up whatever I'm looking at right now" is the reading that needs no new UI and matches what a player pressing it mid-battle almost certainly wants.

Migration note for existing mods

Nothing, for the mod API surface: content.X:register/override/get, events:on, hooks:wrap, mod.log, mod:read, manifest v1 fields are untouched, and GameSpeed.LEVELS/.DEFAULT/.levelLabel/.clamp/.cycle keep their exact signatures and behavior.

One save-data field, for anything that read save.options.speed directly (not a formal registry/hook surface, but worth naming): it is superseded by speedOverworld/speedBattle/speedMenu, migrated automatically on load (see above) so a save from before this RFC keeps its player's chosen speed. A mod reading save.options.speed after this change sees nil (the key is dropped on migration, not kept as a stale alias) and should read the per-category fields, or hook core.logic_speed to observe the resolved multiplier directly regardless of which category produced it.

Parity tests

  • No-mod: core.logic_speed needs no dedicated no-mod test file -- tests/engine/gate_hooks.lua walks the live hook catalog (which scans src for Runtime.call("...") call sites), so the new Runtime.call("core.logic_speed", ...) site is picked up and gated automatically: vanilla runs exactly once with an empty hook chain, an unsubscribed-but-live bus passes values and multiple returns through unchanged, and Runtime.wantsHook reads false.
  • Mod-API: tests/engine/game_speed_categories_test.lua exercises the hook through the public API (Hooks.new() + bus:wrap("core.logic_speed", ...) + Runtime.call, the same idiom other hooks' tests use) -- a subscriber can read the vanilla category resolution via next(game) and can override it outright -- plus direct coverage of Game.speedCategoryInStack (battle-on-top, overworld-on-top, an overlay inheriting each, an empty/unmatched stack falling to "menu") and Game:logicSpeed()'s precedence (link forces 1X over all three categories and over a hook override; the run-argument override wins over the category resolution).
  • tests/run_tests.lua's OptionsMenu walk exercises the three new rows (OVERWORLD SPEED / BATTLE SPEED / MENU SPEED) cycling and wrapping independently, in place of the old single GAME SPEED row.
  • tests/mod_ui_tests.lua's row-id/order check and hardcoded row-index activations (MODS, CONTROLS) are updated for the two extra rows.
  • A link-play driver should set all three per-category speeds high before asserting game:logicSpeed() reads 1 during a real link session, proving the lock wins over every category at once, not just whichever one happens to be active.

Deprecation etiquette

Nothing deprecated in the mod-facing hook/event/registry catalog -- this adds one hook, additive. The save.options.speed field is superseded with an automatic migration rather than a deprecation notice, since it was never a registered mod-API surface (no schema entry, no registry) -- the same treatment any other save.options field would get if it needed reshaping.