Modal-Aware Input Gating Implementation Plan
Modal-Aware Input Gating Implementation Plan
Section titled “Modal-Aware Input Gating Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Stop main.gd’s global hotkeys (HUD toggle, debug overlay, codex, quality, remote, pause) from firing underneath an open modal panel.
Architecture: main is the scene root (Node2D) and its _input() sees events before the child CanvasLayer panels. Add a polled _any_content_modal_open() helper (mirroring the existing _shop_open()/_bay_open() guards), restructure main._input() into three ordered stages (pause keys → modal-suppress gate → decorative hotkeys), and extend _toggle_pause()’s self-guard so ESC won’t stack a pause menu over a content modal. All changes are in main.gd; no panel files change.
Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6.0 (headless).
Global Constraints
Section titled “Global Constraints”main.gd-only change. No panel file (ui/*.gd) is modified — they already consume input correctly viaset_input_as_handled()./simis untouched. This is Node/UI-layer input routing. The determinism baseline intests/test_determinism_survival.gdmust still hold — no re-pin expected.- Follow the existing guard pattern:
main._input()already early-returns on_bay_open()/_shop_open(); the new modal guard uses the same polling shape. - Pause decisions (Chris, 2026-07-14): (1) while a content modal is open, the pause button does nothing (the panel owns input); (2) suppress ALL decorative hotkeys whenever any modal is open.
- Node fields are null-guarded in the helper (
dialogue_box,codex,ship_config,boss_teaser,debug_settingsare assigned in_ready). - Spec:
docs/superpowers/specs/2026-07-14-modal-input-gating-design.md. - Test verification note: GUT’s headless run SIGABRTs (exit 134) at engine teardown even on a fully green suite — judge results from the printed “Run Summary”, not the exit code. Narrow to one script with
-gselect=<script_name_without_.gd>(NOT-gtest/-gdir, which.gutconfig.jsonoverrides).
File Structure
Section titled “File Structure”main.gd— the only production file changed:- New method
_any_content_modal_open() -> bool(placed next to_bay_open()/_shop_open()). - Rewritten
_input(event)— three ordered stages. - One added line in
_toggle_pause()— a self-guard.
- New method
tests/test_main.gd— append 5 GUT tests (bootsmain.tscn, the file’s established pattern).
Two tasks. Task 1 delivers the decorative-hotkey gate (and keeps pause working). Task 2 adds the “don’t stack pause over a content modal” refinement, which depends on Task 1’s helper. A reviewer can meaningfully accept Task 1 (gate works, pause still closes) while separately scrutinizing Task 2’s _toggle_pause guard.
Task 1: _any_content_modal_open() helper + _input() three-stage restructure
Section titled “Task 1: _any_content_modal_open() helper + _input() three-stage restructure”Files:
- Modify:
main.gd— add_any_content_modal_open(); rewrite_input()(currently at ~line 1658). - Test:
tests/test_main.gd(append).
Interfaces:
- Consumes (existing, verified present):
dialogue_box.is_showing(),codex.is_open(),ship_config.is_open(),boss_teaser.is_open(),debug_settings.visible; flags_paused_for_levelup,_paused_for_dimension_choice,_paused_for_menu; methods_bay_open(),_shop_open(),_toggle_pause(),_toggle_tactical_hud(),_toggle_codex(),debug_overlay.toggle(),quality_manager.cycle_override(),control_client.enable(self). - Produces:
_any_content_modal_open() -> bool(used by Task 2).
Context for the implementer: main._input() dispatches every global hotkey. main is the scene root, so it sees input before the child CanvasLayer panels and dispatches its hotkey even when a panel is open. Note that _toggle_tactical_hud() and _toggle_codex() already self-guard sim==null/game_over/_paused_for_menu/_paused_for_levelup/_paused_for_dimension_choice/_story_won — so a dialogue (which they do NOT guard) is the modal that genuinely leaks today, which is why the tests below open a dialogue rather than a level-up.
- Step 1: Write the two failing suppress tests + the two regression-guard tests
Append to tests/test_main.gd:
# ── Modal input gating (2026-07-14) ─────────────────────────────────────────────# main._input() dispatches global hotkeys, but main is the scene root and sees input before the# child CanvasLayer panels — so a decorative hotkey used to fire underneath an open modal. These# guard the fix. A DIALOGUE is used as the open modal because _toggle_tactical_hud()/_toggle_codex()# already self-guard level-up/dimension/pause but NOT a dialogue, so this is the case that truly leaks.
func test_hud_hotkey_is_suppressed_while_a_dialogue_is_open() -> void: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") assert_false(m.weapon_panel.visible, "precondition: HUD hidden by default") m.dialogue_box.consume([{"kind": "say", "name": "Echo", "color": "#ffffff", "lines": ["hi"]}]) assert_true(m.dialogue_box.is_showing(), "precondition: a content modal (dialogue) is open") var ev := InputEventKey.new() ev.physical_keycode = KEY_V # tactical HUD toggle ev.pressed = true m._input(ev) assert_false(m.weapon_panel.visible, "V (HUD toggle) does not fire underneath an open dialogue")
func test_codex_hotkey_is_suppressed_while_a_dialogue_is_open() -> void: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") m.dialogue_box.consume([{"kind": "say", "name": "Echo", "color": "#ffffff", "lines": ["hi"]}]) assert_true(m.dialogue_box.is_showing(), "precondition: a content modal (dialogue) is open") assert_false(m.codex.is_open(), "precondition: codex closed") var ev := InputEventKey.new() ev.physical_keycode = KEY_C # codex toggle ev.pressed = true m._input(ev) assert_false(m.codex.is_open(), "C does not open the codex underneath an open dialogue")
func test_decorative_hotkeys_still_work_with_no_modal_open() -> void: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") assert_false(m.weapon_panel.visible, "precondition: HUD hidden, no modal open") var ev := InputEventKey.new() ev.physical_keycode = KEY_V ev.pressed = true m._input(ev) assert_true(m.weapon_panel.visible, "with no modal open, V still toggles the HUD (gate didn't over-suppress)")
func test_pause_key_still_closes_an_open_pause_menu() -> void: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") m._toggle_pause() assert_true(m._paused_for_menu, "precondition: pause menu is open") var esc := InputEventKey.new() esc.physical_keycode = KEY_ESCAPE esc.pressed = true m._input(esc) assert_false(m._paused_for_menu, "ESC still closes the pause menu after the _input restructure")- Step 2: Run the tests to verify the two suppress tests fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_main -gexit 2>&1 | grep -iE "Passing|Failing|\[Failed\]"
Expected: test_hud_hotkey_is_suppressed_while_a_dialogue_is_open and test_codex_hotkey_is_suppressed_while_a_dialogue_is_open FAIL (without the gate, V toggles the HUD and C opens the codex under the dialogue). test_decorative_hotkeys_still_work_with_no_modal_open and test_pause_key_still_closes_an_open_pause_menu PASS already (regression guards for the restructure).
- Step 3: Add the
_any_content_modal_open()helper
In main.gd, next to _bay_open() / _shop_open(), add:
# True while any input-owning content panel is up (dialogue, level-up, dimension-choice, codex,# ship-config, boss-teaser, debug-settings). main._input() polls this each event — same pattern as# _shop_open()/_bay_open() — to suppress its decorative hotkeys underneath an open panel, and# _toggle_pause() checks it so ESC won't stack a pause menu over one. The pause menu itself is# tracked separately by _paused_for_menu (checked alongside this in _input). Level-up/dimension use# their authoritative _paused_for_* flags; the rest expose is_showing()/is_open(); debug-settings has# no accessor so we read .visible. Every field is null-guarded (assigned in _ready).func _any_content_modal_open() -> bool: return (dialogue_box != null and dialogue_box.is_showing()) \ or _paused_for_levelup \ or _paused_for_dimension_choice \ or (codex != null and codex.is_open()) \ or (ship_config != null and ship_config.is_open()) \ or (boss_teaser != null and boss_teaser.is_open()) \ or (debug_settings != null and debug_settings.visible)- Step 4: Rewrite
_input()into three ordered stages
Replace the entire body of main.gd’s func _input(event: InputEvent) -> void: with:
func _input(event: InputEvent) -> void: if _bay_open(): return # the Drone Bay overlay owns input while it's up if _shop_open(): return # the shop overlay owns input while it's up — suppress main's hotkeys/pause # Stage 1 — pause keys (controller Start/Back, keyboard ESC) FIRST, so they stay reachable to # CLOSE an open pause menu. _toggle_pause() self-guards against opening over any other modal. if event is InputEventJoypadButton and event.pressed and (event.button_index == JOY_BUTTON_START or event.button_index == JOY_BUTTON_BACK): _toggle_pause() return if event is InputEventKey and event.pressed and not event.echo and event.physical_keycode == KEY_ESCAPE: _toggle_pause() return # Stage 2 — a modal owns input: suppress every decorative hotkey underneath it. The pause menu # itself (_paused_for_menu) counts too; shop/bay are already handled above. if _paused_for_menu or _any_content_modal_open(): return # Stage 3 — decorative hotkeys, only when no modal is open. # Y button: toggle the tactical HUD (weapon + drone docks), hidden by default. if event is InputEventJoypadButton and event.pressed and event.button_index == JOY_BUTTON_Y: _toggle_tactical_hud() return # X button: toggle the perf/debug overlay (mirrors desktop F2 below). The Apple TV has no # keyboard, so F2 alone left no way to show/hide it there. Chris, 2026-07-02. if event is InputEventJoypadButton and event.pressed and event.button_index == JOY_BUTTON_X: debug_overlay.toggle() return if event is InputEventKey and event.pressed and not event.echo: if event.physical_keycode == KEY_V: _toggle_tactical_hud() return if event.physical_keycode == KEY_C: _toggle_codex() return if event.physical_keycode == KEY_F2: debug_overlay.toggle() elif event.physical_keycode == KEY_F4: # Dev override: cycle AUTO -> fixed tiers -> AUTO. Adaptive graphics # normally manages fidelity on its own (no input needed on tvOS). quality_manager.cycle_override() elif event.physical_keycode == KEY_F6 and BuildConfig.dev_tools(): # Arm the remote-control console (DEV builds only). Once armed, polls every second. if control_client.pairing_code == "": control_client.enable(self)- Step 5: Run the tests to verify all four pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_main -gexit 2>&1 | grep -iE "Passing|Failing|All tests passed|\[Failed\]"
Expected: all test_main tests pass (the two suppress tests now green, the two guards still green). “All tests passed!” printed before the teardown SIGABRT.
- Step 6: Commit
git add main.gd tests/test_main.gdgit commit -m "fix(main): suppress decorative hotkeys under open modal panels
main is the scene root, so its _input() saw hotkeys (HUD toggle, codex, debugoverlay, quality, remote) before the child CanvasLayer panels and dispatched themeven with a panel open. Restructure _input() into three stages: pause keys first(stay reachable to close the pause menu), then a modal-suppress gate via a new polled_any_content_modal_open() helper, then the decorative hotkeys. main.gd-only; /simuntouched, determinism unaffected."Task 2: Extend _toggle_pause() so ESC won’t stack a pause menu over a content modal
Section titled “Task 2: Extend _toggle_pause() so ESC won’t stack a pause menu over a content modal”Files:
- Modify:
main.gd— one added guard line in_toggle_pause()(currently at ~line 326). - Test:
tests/test_main.gd(append one test).
Interfaces:
- Consumes:
_any_content_modal_open()(from Task 1),_paused_for_menu,_close_pause(). - Produces: nothing new.
Context: After Task 1, ESC over a dialogue still opens the pause menu, because _toggle_pause() doesn’t guard content modals (only sim==null/game_over/_paused_for_levelup/_paused_for_dimension_choice/_story_won). Chris’s decision: a content modal owns input, so pause should do nothing while one is open.
- Step 1: Write the failing test
Append to tests/test_main.gd:
func test_pause_key_does_not_open_a_pause_menu_over_a_content_modal() -> void: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") m.dialogue_box.consume([{"kind": "say", "name": "Echo", "color": "#ffffff", "lines": ["hi"]}]) assert_true(m.dialogue_box.is_showing(), "precondition: a content modal is open") assert_false(m._paused_for_menu, "precondition: pause menu not open") var esc := InputEventKey.new() esc.physical_keycode = KEY_ESCAPE esc.pressed = true m._input(esc) assert_false(m._paused_for_menu, "ESC does not stack a pause menu over an open dialogue")- Step 2: Run the test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_main -gexit 2>&1 | grep -iE "Passing|Failing|\[Failed\]"
Expected: test_pause_key_does_not_open_a_pause_menu_over_a_content_modal FAILS (ESC → _toggle_pause() opens the pause menu because it doesn’t yet guard the dialogue → _paused_for_menu becomes true).
- Step 3: Add the self-guard to
_toggle_pause()
In main.gd’s _toggle_pause(), insert the new guard immediately after the existing _paused_for_menu close-path block and before _paused_for_menu = true:
if _paused_for_menu: _close_pause() return if _any_content_modal_open(): return # a dialogue/codex/ship-config/etc. owns input — don't stack a pause menu on top _paused_for_menu = true pause_menu = PauseMenu.new()(The first two lines and the last two lines are existing anchors — insert only the two-line if _any_content_modal_open() guard between them.)
- Step 4: Run the test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_main -gexit 2>&1 | grep -iE "Passing|Failing|All tests passed|\[Failed\]"
Expected: all test_main tests pass, including the new one.
- Step 5: Verify determinism baseline + boot smoke + full suite
Run:
# determinism (main.gd-only change → must still hold, no re-pin)godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_survival -gexit 2>&1 | grep -iE "All tests passed|\[Failed\]"# boot smoke — no SCRIPT ERRORgodot --headless --path . --quit-after 30 2>&1 | grep -i "SCRIPT ERROR" || echo "boot clean"# full suite — 0 failing (pre-existing Risky/Pending tests are fine)godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | grep -A26 "= Run Summary" | grep -iE "^Scripts|^Tests|^Passing|^Failing|^Risky"Expected: determinism “All tests passed!”; boot “boot clean”; full suite 0 failing (Failing line absent or 0), only the pre-existing 4 Risky/Pending.
- Step 6: Commit
git add main.gd tests/test_main.gdgit commit -m "fix(main): pause key won't stack a pause menu over a content modal
_toggle_pause() already self-guarded level-up/dimension/game-over but not the contentmodals (dialogue, codex, ship-config, boss-teaser, debug-settings). Add one guard soESC/Start does nothing while one is open — the panel owns input (Chris's call). The_paused_for_menu close-path stays above the guard, so closing an open pause menu stillworks. Determinism baseline held; full suite green."Self-Review
Section titled “Self-Review”1. Spec coverage:
- Problem (hotkeys leak under panels) → Task 1 gate. ✓
- Decision 1 (pause does nothing over content modal) → Task 2
_toggle_pauseguard. ✓ - Decision 2 (suppress ALL decorative hotkeys under any modal) → Task 1 Stage-2 gate (
_paused_for_menu or _any_content_modal_open()). ✓ - Pause key stays able to close the pause menu → Task 1 Stage 1 +
test_pause_key_still_closes_an_open_pause_menu. ✓ - Helper polls panel state, null-guarded, uses
_paused_for_*for level-up/dimension → Task 1 Step 3. ✓ - Determinism unaffected, no panel changes → Global Constraints + Task 2 Step 5. ✓
- Testing (6 items: HUD suppressed, codex suppressed, pause closes, pause-not-over-modal, no-modal regression, boot smoke) → all present across Tasks 1–2 Step 5. ✓
2. Placeholder scan: No TBD/TODO/“handle edge cases”. Every code step shows complete code. ✓
3. Type consistency: _any_content_modal_open() -> bool defined in Task 1 Step 3, consumed in Task 2 Step 3 — same name/signature. Test helper shapes (InputEventKey.new() with .physical_keycode/.pressed; the {"kind":"say",...} dialogue event) match the verified dialogue_box.consume() and main._input() contracts. ✓