Skip to content

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

  • main.gd-only change. No panel file (ui/*.gd) is modified — they already consume input correctly via set_input_as_handled().
  • /sim is untouched. This is Node/UI-layer input routing. The determinism baseline in tests/test_determinism_survival.gd must 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_settings are 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.json overrides).

  • 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.
  • tests/test_main.gd — append 5 GUT tests (boots main.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
Terminal window
git add main.gd tests/test_main.gd
git commit -m "fix(main): suppress decorative hotkeys under open modal panels
main is the scene root, so its _input() saw hotkeys (HUD toggle, codex, debug
overlay, quality, remote) before the child CanvasLayer panels and dispatched them
even 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; /sim
untouched, 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:

Terminal window
# 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 ERROR
godot --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
Terminal window
git add main.gd tests/test_main.gd
git 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 content
modals (dialogue, codex, ship-config, boss-teaser, debug-settings). Add one guard so
ESC/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 still
works. Determinism baseline held; full suite green."

1. Spec coverage:

  • Problem (hotkeys leak under panels) → Task 1 gate. ✓
  • Decision 1 (pause does nothing over content modal) → Task 2 _toggle_pause guard. ✓
  • 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. ✓