Controller Handling - Roadmap
Generated: July 2, 2026, last updated same day after a full scene-by-scene
focus/navigation audit and fix pass (Phase 0b, this PR). This document scopes
what a "strong, professional" controller/input system looks like for this
project and is written to be a standalone handoff - read "State of the world"
first if picking this up cold.
State of the world (read this first)
Repo: itsalovelyday/roguelike_demo. Working dir for everything below
is game-engine/ (the C# Godot 4 project). Input is currently split across
three concerns that don't share an abstraction:
project.godot[input]section — the InputMap. Ten named actions:
ui_accept/cancel/left/right/up/down(keyboard + D-pad, Godot built-ins),
cursor_left/right/up/down(left-stick axes, deadzone 0.2), and now
end_turn(Joypad button 3 / Xbox-Y / PS-Triangle, added in Phase 0).VirtualCursor.cs(autoload singleton,res://scripts/VirtualCursor.cs)
— the real gamepad-navigation engine. Runs two parallel modes that hand
off based on last input type: stick-driven virtual mouse (warps the real
OS cursor viaWarpMouse, synthesizesInputEventMouseButtonon
ui_accept) vs. D-pad-driven native Control focus traversal (falls back
toTryGrabFocusFromHover, walking up from the hovered Control to find a
focusable ancestor, when nothing has focus).GlobalInput.cs(autoload) — the only true top-level input router;
watchesui_canceland togglesSettingsOverlay. Everything else
(CombatManager.cs,TrophyCase.cs,StartScreen.cs,
SettingsOverlay.cs) just calls.GrabFocus()on a default Control in
_Ready()to seed gamepad navigation, or readsInput/InputEvent
directly for one specific local need.
Autoload order (matters for init dependencies):
PlayerState → MetaProgress → Achievements → GlobalInput → AudioManager → VirtualCursor.
Phase 0 (done, this PR):
- CombatManager.cs:_UnhandledInput hardcoded
InputEventJoypadButton joyBtn && joyBtn.ButtonIndex == JoyButton.Y to
jump focus to the End Turn button. Replaced with a named end_turn
InputMap action (@event.IsActionPressed("end_turn")) bound to the same
physical button, so behavior is unchanged today but the binding now lives
in one place instead of being buried in game logic.
- Removed BattleManager.cs / ShopManager.cs — one-line deprecation-notice
stub files with zero references anywhere in the project (.csproj has no
explicit <Compile> entries; Godot globs *.cs, so these were dead
weight, not wiring).
Phase 0b (done, this PR) — full scene-by-scene focus audit: audited all
~16 top-level scenes for (a) default focus on open, (b) explicit
FocusNeighbor wiring on dynamic/irregular layouts, (c) focus restoration
on modal close. CombatScene, ShopScene, OrgChart, EventScene, and
SectorSelectionScene were already well-instrumented (mature, deliberate
gamepad engineering — explicit neighbor wiring, CallDeferred focus grabs
re-run after every dynamic rebuild). Fixed the gaps found in the rest:
- RewardUI.cs had no GrabFocus() anywhere — a controller player
landed on every combat-victory reward screen (cards/boon/totems, all
spawned on staggered reveal timers) with nothing focused and D-pad inert.
Added GrabRewardFocusOnce(), called from each reward type's reveal
callback, so whichever option appears first claims focus once.
- UpgradeSelectionUI.cs had the same "nothing focused" gap on its
2-3-option upgrade-path screen. Added explicit left/right FocusNeighbor
wiring across the row (it's wrapped in a CenterContainer, where Godot's
automatic nearest-Control algorithm gets unreliable) plus an initial
GrabFocus() on the first option.
- Pause-menu SettingsOverlay (GlobalInput.cs's dynamically
instantiated instance): opened fine (ResumeButton.GrabFocus()) but
Close() never restored focus to whatever was focused in the underlying
scene — resuming combat left D-pad dead until the next turn transition
happened to re-grab focus. Added PreviousFocusOwner, captured by
GlobalInput before the overlay steals focus and restored in Close().
(StartScreen's embedded settings panel already handled this correctly
via its own SettingsBackButton path — untouched, confirmed not to share
code with the fix.)
- StartScreen.cs's card-library grid: initial focus landed on the
Close button instead of the first card. Now lands on the first card in
the (already-filtered) grid, falling back to Close only if the filter
results are empty.
- TrophyCase.cs's run-details popup: closing it never returned focus
to the trophy slot that opened it, leaving focus on a freed Control.
Added _triggeringTrophyButton tracking, restored on close.
- Also removed five more dead scene/script pairs found during the audit
(MainScene, RecruitmentScene, WorkerScene, CandidateCard,
CardUpgradeUI — all one-line "DEPRECATED" stubs with zero references,
same pattern as Phase 0's BattleManager/ShopManager; CardUpgradeUI
specifically superseded by UpgradeSelectionUI).
Known remaining gap, not fixed here: DeckView.cs's paginated deck
grid has no explicit FocusNeighbor wiring, relying on Godot's automatic
nearest-Control algorithm. Left alone deliberately — it's a uniform
GridContainer (the case Godot's default algorithm actually handles well,
unlike the CenterContainer-wrapped cases above), so it's a "nice to make
explicit for robustness" item, not a "broken today" one. Worth revisiting
if it turns out to misbehave on real hardware.
Phase 1 (done) — regression guard: added scripts/audit_input_bypasses.py,
following the audit_dead_cards.py/audit_enemy_damage.py convention —
greps game-engine/scripts/*.cs for a raw .ButtonIndex/.Keycode/
.PhysicalKeycode/.Axis comparison against a hardcoded JoyButton.*/
Key.*/JoyAxis.* literal (the exact shape of the bug Phase 0 fixed),
outside EXEMPT_FILES (VirtualCursor.cs, and ControlsRemapPanel.cs from
Phase 2 below — capturing a raw event is its job). Wired into
scripts/test_combat_regression.py's main(), so it runs on every push/PR
via the existing combat-regression.yml CI job — no separate workflow
needed, it's a cheap static check that piggybacks on the existing runner.
Phase 2 (done) — rebind UI + persisted overrides: implemented as
scoped, with one deliberate scope-narrowing from the original plan (see
below).
- InputRemapService.cs (static class, not an autoload — it's called
from GameSettings.LoadSettings(), the existing single "load every
persisted preference" entry point, so it initializes at the same point as
every other setting rather than needing its own lifecycle). Snapshots
InputMap's project.godot defaults per rebindable action
(CacheDefaults(), must run before anything mutates the map), then
applies any saved overrides on top (LoadOverrides()). Rebind(action,
newEvent) replaces only the existing event of the same device type
(so rebinding the gamepad button never wipes the keyboard key and vice
versa — the "separate slots per device" design called out below) and, if
newEvent is already claimed by a different rebindable action, steals it
from that action first so one physical input never fires two actions.
Persists via ConfigFile to the same user://settings.cfg
GameSettings already writes (new [InputOverrides] section) rather than
a second file, since there's no reason to split it.
- Scope-narrowing from the original plan: only ui_accept, ui_cancel,
ui_up/down/left/right, and end_turn are rebindable — NOT
cursor_left/right/up/down (the left-stick axis actions). Remapping an
analog stick axis via a "press a button to capture" flow isn't a coherent
interaction (there's no single button-press that means "the stick"), and
no mainstream game exposes stick-axis remapping that way — it's out of
scope for a capture-based rebind UI, full stop, not a "later phase" item.
- ControlsRemapPanel.cs (new file, no companion .tscn — entirely
code-built, matching UpgradeSelectionUI.cs's fully-procedural-screen
convention rather than hand-authoring N near-identical rows in the
editor). One row per rebindable action: current binding label, "Rebind
Key" / "Rebind Gamepad" buttons (separate slots, per the device-type
design above), Reset. Capture happens in _Input (fires before Godot's
own D-pad focus-navigation consumes the same event) and is the one
legitimate place in ControlsRemapPanel.cs that reads a raw
InputEventKey/InputEventJoypadButton instead of a named action —
exempted in the Phase 1 audit script. Escape cancels a keyboard capture
(hardcoded on purpose: Escape is already ui_cancel's default keyboard
binding, so there's no real use case for reassigning it); gamepad capture
has no auto-cancel button since B/Start are legitimate rebind targets —
a real on-screen Cancel button (mouse/virtual-cursor clickable, unaffected
by the capture-mode _Input interception since it only intercepts
key/joypad-button event types) covers both modes instead.
- SettingsOverlay.tscn/.cs: the settings menu already had a
dead "Controls" grid row (LabelControlsInfo, static text reading "Mouse
Only (Escape to Pause)" — inaccurate given how much gamepad support
already exists). Converted that row's second cell from a Label to a
real Button (RemapControlsButton) that opens ControlsRemapPanel,
and updated the translation string. New translation keys added to
resources/translations.csv (UI_REMAP_CONTROLS_TITLE,
UI_REBIND_KEYBOARD, UI_REBIND_GAMEPAD, UI_RESET, UI_RESET_ALL,
UI_PRESS_ANY_INPUT) — the compiled .translation resources are
gitignored and regenerated from this CSV on Godot's next import, so no
stale-binary risk from editing it outside the editor.
Phase 3 — Harden VirtualCursor's dual-mode handoff
Done (this PR), two of three items:
- Fallback focus target: added VirtualCursor.DefaultFocusAnchor (a
static Control property) — each screen's existing default-focus call
site (CombatManager.UI.cs GrabHandFocus, ShopUI.GrabInitialShopFocus,
OrgChartUI.GrabInitialFocus, EventUI.GrabInitialEventFocus,
SectorSelectionUI.GrabInitialFocus, RewardUI.GrabRewardFocusOnce,
UpgradeSelectionUI.GrabInitialFocus, StartScreen's initial
StartButton.GrabFocus(), TrophyCase's initial _backButton.GrabFocus())
now also sets this alongside its own GrabFocus() call. VirtualCursor.
TryGrabFocusFromHover() falls back to it when the hover-walk finds
nothing focusable, instead of leaving D-pad navigation anchored nowhere.
Deliberately not a scene-tree-walking / CurrentScene-interface design
as originally sketched above — UpgradeSelectionUI is sometimes added
directly to GetTree().Root rather than reached via ChangeSceneToFile,
so CurrentScene isn't reliably "whatever's actually showing." A simple
"last thing that claimed to be the default" static field sidesteps that
and works the same for both true scene transitions and root-level
overlays. Modal overlays (SettingsOverlay, ControlsRemapPanel) are
deliberately excluded — they already have the separate, correct
PreviousFocusOwner restore-on-close mechanism from Phase 0b, and
routing them through DefaultFocusAnchor too would just create two
competing sources of truth for "what's the fallback" while a modal is up.
- Multi-controller comment: confirmed and documented in
VirtualCursor.cs — Input.GetVector(...) intentionally reads the
combined state of every connected joypad (single shared cursor/focus
target, correct for this single-player game, not an oversight).
Not done — needs art assets, out of scope for a code-only pass:
- Button-prompt glyphs (Xbox/PlayStation icon swap) would need actual icon
image assets; none exist anywhere in game-engine/assets/ today (checked
before starting this phase). The information this would have conveyed
already shipped a different way in Phase 2:
InputRemapService.JoypadButtonLabel() renders text labels ("D-Pad Up",
"Gamepad Y / Triangle", etc.) in the rebind panel, which is the practical
equivalent without needing new art. Revisit as an actual art-asset task,
not a code task, if it's ever prioritized.
Phase 4 (stretch) — Decouple gameplay code from raw Input reads
Goal: right now, gameplay controllers (CombatManager.cs et al.) read
Input/InputEvent directly for the one thing they care about (end_turn).
That's fine at the current scale (one call site), but if more gameplay
shortcuts get added, consider an InputActions signal bus: a thin autoload
that owns all _UnhandledInput dispatch, translates recognized InputMap
actions into C# signals/events (EndTurnRequested, etc.), and gameplay
controllers subscribe instead of implementing _UnhandledInput themselves.
This is genuinely optional — don't do it speculatively before Phase 2 proves
out whether more actions are coming. Listed here so it's not forgotten if
the input surface grows.
Suggested sequencing
Phases 1, 2, and 3 are done (this PR), except button-prompt glyphs, which
is now an art-asset task rather than a code task (see Phase 3). Phase 4
(input-action signal bus) remains a "only if it becomes a real problem"
note, not a commitment — the input surface hasn't grown enough to justify
it yet. There is no more code-only work left on this roadmap; the next
real step is the manual build/playtest pass below, and then, separately,
someone sourcing/commissioning controller button icon art if the glyph
item is ever prioritized.
Not yet manually verified: no dotnet/godot available in this
sandbox to build the project or click through the actual rebind flow.
Recommend a build + manual pass before merge: open Settings → Remap
Controls, rebind a couple of actions on both keyboard and gamepad, confirm
the binding label updates, confirm Reset/Reset All work, quit and relaunch
to confirm the override persisted, and confirm stealing a binding from
another action (e.g. rebinding end_turn to the same key as ui_cancel)
correctly clears it from the other action instead of double-binding.