Skip to content

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.

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.

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.

  1. 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.
  2. Decorative hotkeys: suppress all of them (HUD toggle, debug overlay, codex, quality, remote) whenever any modal is open — including the pause menu itself.

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.

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’ visible state 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 visible directly, so read .visible.
  • Every node field is null-guarded. These fields are assigned in _ready, and _input cannot 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 _input stage 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.

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

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.

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.

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:

  1. 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 via m._input(evt), assert HUD visibility is unchanged.
  2. Codex suppressed under a modal — same setup, feed C, assert codex.is_open() stays false.
  3. Pause key still closes an open pause menu — open the pause menu (_paused_for_menu true), feed ESC, assert _paused_for_menu is false / pause menu freed.
  4. Pause key does not open pause over a content modal — content modal up, _paused_for_menu false, feed ESC, assert _paused_for_menu stays false (no pause menu instantiated).
  5. 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).
  6. Headless boot smoke — godot --headless --path . --quit-after 20, grep stderr for SCRIPT ERROR (none).

Determinism: run test_determinism_survival to confirm the baseline still holds (expected: it does — no /sim change).

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