Modal-Aware Input Gating — Design
Modal-Aware Input Gating — Design
Section titled “Modal-Aware Input Gating — Design”Date: 2026-07-14 Status: Approved (Chris, 2026-07-14) Origin: overnight-review triage item — “Input-leak family (DialogueBox / ShipConfigPanel / PauseMenu don’t consume unmatched events → hotkeys fire under open panels)”. Flagged as needing a project-wide decision, not a blind per-file fix.
Problem
Section titled “Problem”main._input() (main.gd:1658) dispatches every global hotkey — pause (ESC / Start / Back),
tactical-HUD toggle (Y / V), perf-debug overlay (X / F2), codex (C), quality override (F4),
remote console (F6). It early-returns only for two overlays: _bay_open() and _shop_open().
main is the scene root (Node2D), so its _input() sees events before any child
CanvasLayer panel. For every OTHER modal panel — DialogueBox, LevelUpPanel,
DimensionChoicePanel, PauseMenu, CodexOverlay, ShipConfigPanel, DebugSettingsPanel,
BossTeaserOverlay — main dispatches its hotkey regardless of whether a panel is open. The
panels themselves correctly consume the events they DO handle (nav/confirm/cancel, each
calling get_viewport().set_input_as_handled()), but any hotkey they don’t handle (e.g. C
to open the codex while a dialogue is up) fires underneath them. Result: pressing C during a
dialogue opens the codex on top of it; pressing Y during a level-up toggles the HUD; etc.
The sim is already frozen while these panels are open (_paused_for_menu /
_paused_for_levelup / _paused_for_dimension_choice / _warping gate the tick), so this is
purely an input-routing gap, not a gameplay-logic one.
Load-bearing wrinkle: the pause key
Section titled “Load-bearing wrinkle: the pause key”PauseMenu has no ESC-to-close handler of its own — it exposes a “Resume” button and relies
on main’s ESC → _toggle_pause() → _close_pause() to dismiss it. So the fix cannot be a
blanket “return early if any panel is open”: the pause key must stay reachable to close the
pause menu. _toggle_pause() already self-guards sim == null / game_over /
_paused_for_levelup / _paused_for_dimension_choice / _story_won, but not
dialogue / codex / ship-config / boss-teaser / debug-settings.
Decisions (Chris, 2026-07-14)
Section titled “Decisions (Chris, 2026-07-14)”- Pause + content modals: while a content modal (dialogue, codex, ship-config, boss-teaser) is open, the pause button does nothing — the panel owns input; you dismiss it first, then pause. This matches how level-up / dimension-choice already behave.
- Decorative hotkeys: suppress all of them (HUD toggle, debug overlay, codex, quality, remote) whenever any modal is open — including the pause menu itself.
Architecture choice
Section titled “Architecture choice”main learns “is a modal open?” by polling panel state each _input(), via a new
_any_content_modal_open() helper, exactly like the existing _shop_open() / _bay_open()
guards. Rejected alternative: a _modal_depth counter that panels increment/decrement — more
testable in isolation but fragile (any open-without-close path leaves hotkeys stuck-off). Polling
cannot drift out of sync because it reads the panels’ own authoritative state every event.
All changes are in main.gd. No panel files change — they already consume input correctly.
Components
Section titled “Components”1. _any_content_modal_open() -> bool (new helper in main.gd)
Section titled “1. _any_content_modal_open() -> bool (new helper in main.gd)”Returns true when any input-owning content panel is up:
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)- Level-up / dimension-choice use the authoritative
_paused_for_*flags (the panels’visiblestate mirrors these, but the flag is the source of truth for “we are in this modal state”). - DialogueBox / CodexOverlay / ShipConfigPanel / BossTeaserOverlay expose
is_showing()/is_open()accessors (verified present). - DebugSettingsPanel has no accessor — it toggles
visibledirectly, so read.visible. - Every node field is null-guarded. These fields are assigned in
_ready, and_inputcannot fire before a node is in the tree, so the guards are defensive insurance, not a live path. - Not included here (handled separately): shop + drone bay (existing
_shop_open()/_bay_open()early-return, kept as-is) and the pause menu (_paused_for_menu, checked explicitly in_inputstage 2 — see below).
2. main._input() restructured into three ordered stages
Section titled “2. main._input() restructured into three ordered stages”func _input(event: InputEvent) -> void: if _bay_open(): return if _shop_open(): return # Stage 1 — pause keys (Start / Back / ESC) FIRST: must 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. if _paused_for_menu or _any_content_modal_open(): return # Stage 3 — decorative hotkeys (unchanged): Y/V HUD, X/F2 debug, C codex, F4 quality, F6 remote. ...The ESC handler moves out of the combined InputEventKey block up into Stage 1; the remaining
key hotkeys (V, C, F2, F4, F6) stay in Stage 3 exactly as they are today.
3. _toggle_pause() self-guard extension
Section titled “3. _toggle_pause() self-guard extension”Add one line so ESC won’t stack a pause menu over a content modal:
func _toggle_pause() -> void: if sim == null or sim.game_over or _paused_for_levelup or _paused_for_dimension_choice or _story_won: return if _paused_for_menu: _close_pause() return if _any_content_modal_open(): # NEW — don't open pause on top of a dialogue/codex/etc. return _paused_for_menu = true ...The _paused_for_menu → _close_pause() path stays above the new guard, so closing an open
pause menu is unaffected. Level-up / dimension are already covered by the first-line guard (and
redundantly by the helper — harmless and consistent).
Data flow / determinism
Section titled “Data flow / determinism”Input-gating only, entirely within main.gd (Node layer). /sim is untouched. The determinism
baseline is unaffected — no re-pin. No new RNG draws, no tick-path changes.
Error handling
Section titled “Error handling”The helper null-guards each panel field. No other failure modes: the change only ever suppresses a dispatch that already had no business firing; it never introduces a new action.
Testing (GUT, test_main.gd pattern)
Section titled “Testing (GUT, test_main.gd pattern)”test_main.gd already boots the real main via preload("res://main.tscn").instantiate() +
add_child_autofree(m) + await get_tree().process_frame, and asserts hotkey effects (there is
an existing test_tactical_hud_starts_hidden_and_toggle_shows_and_hides_it). New tests follow
that pattern:
- HUD toggle suppressed under a modal — boot main, put a content modal up (e.g. show a
dialogue so
dialogue_box.is_showing()is true), record HUD visibility, feed the Y (or V) event viam._input(evt), assert HUD visibility is unchanged. - Codex suppressed under a modal — same setup, feed
C, assertcodex.is_open()stays false. - Pause key still closes an open pause menu — open the pause menu (
_paused_for_menutrue), feed ESC, assert_paused_for_menuis false / pause menu freed. - Pause key does not open pause over a content modal — content modal up,
_paused_for_menufalse, feed ESC, assert_paused_for_menustays false (no pause menu instantiated). - Regression guard — hotkeys still work with no modal open — no modal, feed Y, assert the HUD toggles (proves the gate didn’t over-suppress the normal path).
- Headless boot smoke —
godot --headless --path . --quit-after 20, grep stderr forSCRIPT ERROR(none).
Determinism: run test_determinism_survival to confirm the baseline still holds (expected: it
does — no /sim change).
Out of scope
Section titled “Out of scope”- Panel input handlers (already correct — no change).
- Shop / Drone Bay guards (already correct — kept as-is).
- Any new modal-management abstraction / registry (YAGNI — the polling helper suffices).
- The other open triage item (2-pilot determinism test) — separate spec.