Skip to content

Start Menu Carousel Declutter — design

The main menu (ui/start_menu.gd) currently shows a mode carousel that, under the v0.1 launch gate (V01_CRYSTALS_ONLY = true), renders exactly one card (“SURVIVAL”) — alongside a separate VBoxContainer row of footer buttons (Ship, and Shop/Enemies once the player has progressed, Remote Control in dev builds) sitting below it. Chris: “it still only has the Survival option, we should [not] have any other buttons on there just the carousel to make it clear.” Fold the footer buttons into the carousel itself as additional cards, so the carousel is the only interactive element on the screen and genuinely has more than one card to browse.

ui/start_menu.gd was restyled into a carousel on 2026-07-05 (docs/superpowers/specs/2026-07-05-start-menu-carousel-design.md), reusing the shop’s hologram-bracket carousel pattern (ui/meta_shop_panel.gd). That design explicitly kept the footer buttons as-is — “the ‘Ship’ footer button… is unchanged; it’s now the only way to reach ship selection.” This design supersedes that one footer-button decision only; the carousel mechanics (paging, drag, reveal, hologram brackets) are untouched.

Ship/Shop/Enemies/Remote Control must stay reachable before the player’s first run, since pause_menu (which also exposes Shop/Enemies/Ship mid-run) isn’t instantiated until a run starts. Today: Ship is always shown (footer button, unconditional); Shop and Enemies are gated behind has_progressed (true once meta.banked_gold > 0 and meta.seen_enemies.size() > 0 — nothing to shop for or browse on a first launch); Remote Control is gated behind BuildConfig.dev_tools() (a dev-only playtest console, never shown in release/App Store builds). All three gates are preserved exactly, just moved from “does this footer button exist” to “does this carousel item exist.”

Confirmed with Chris via AskUserQuestion (2026-07-14): fold Ship/Shop/Enemies/Remote Control into the carousel as extra cards (not a separate corner icon, not a hotkey-only path for Remote Control).

  • No change to the carousel’s paging/drag/reveal mechanics, hologram-bracket visuals, or CENTER_SCALE/SIDE_SCALE/SIDE_ALPHA/etc constants — those are validated and untouched.
  • No change to main.gd — it already listens for ship_config_requested/ shop_requested/bestiary_requested/remote_requested and hides/restores start_menu.visible around each; those signals and that flow are reused verbatim, only what triggers them moves from a footer Button.pressed to a carousel card confirm.
  • No change to V01_CRYSTALS_ONLY, has_progressed, or BuildConfig.dev_tools() gating — same three gates, same conditions, just relocated.
  • No change to ShipConfigPanel, MetaShopPanel, or the codex/bestiary overlay themselves.

_items entries grow a 5th field: [id, title, desc, accent, kind], kind ∈ "mode" | "action". Mode entries (survival, plus tutorial/story when V01_CRYSTALS_ONLY is off) are built exactly as today. Four action entries are appended after the mode(s), gated identically to today’s footer buttons:

_items.append(["ship_config", "SHIP", "Choose your hull, view loadout",
Color(0.72, 0.45, 1.0), "action"]) # always
if has_progressed:
_items.append(["shop", "SHOP", "Spend gold, unlock upgrades",
Color(1.0, 0.84, 0.3), "action"])
_items.append(["bestiary", "ENEMIES", "Browse the bestiary",
Color(0.5, 0.85, 1.0), "action"])
if BuildConfig.dev_tools():
_items.append(["remote", "REMOTE", "Dev playtest console",
Color(0.6, 0.62, 0.7), "action"])

Accent colors are carried over unchanged from today’s footer-button colors. _make_mode_card additionally tags the built card with card.set_meta("kind", kind) (alongside the existing card.set_meta("mode", id)) so confirm/press handlers can dispatch without re-deriving kind from _items.

2. Confirm dispatch: _choose(mode) → _activate(id, kind)

Section titled “2. Confirm dispatch: _choose(mode) → _activate(id, kind)”

Every site that currently calls _choose(String(card.get_meta("mode"))) — the center card’s pressed connection in _render_carousel, the same in _complete_drag, and the ui_accept/joypad-A branch in _input — reads both metas and calls a new _activate:

func _activate(id: String, kind: String) -> void:
if kind == "mode":
visible = false
mode_chosen.emit(id)
return
match id:
"ship_config": ship_config_requested.emit()
"shop": shop_requested.emit()
"bestiary": bestiary_requested.emit()
"remote": _on_remote_requested()

kind == "mode" reproduces _choose’s exact old body (hide + emit), so _choose is deleted rather than kept as a thin wrapper (no other caller). For kind == "action", start_menu.visible is deliberately left untouched — this matches today’s footer-button pressed handlers exactly (they never touch visible either; main.gd’s own connected callbacks own hiding/restoring start_menu around Shop/Enemies/Ship, and Remote Control never hides the menu at all, per _on_remote_requested’s existing comment “Stay on the menu so the player can still choose a mode after arming remote control”). No changes to main.gd are needed — same four signals, same four listeners, same hide/restore callbacks; only the emitter moves from a Button.pressed to a carousel card confirm.

The _input confirm branch’s stale comment (“It can only ever be a mode-carousel card… footer buttons stopped being appended to _cards”) is deleted along with the code it described — _cards[0] can now legitimately be an action card.

Section titled “3. Deleted: the footer-button block and its now-dead helpers”

Removed from _ready(): the ship_cfg_btn/shop_btn/codex_btn/remote_btn construction (each _footer_btn(...) call, box.add_child(...), .pressed.connect(...), and their _reveal.append(...) entries). _footer_btn() and _card_box() are deleted entirely — after this change nothing else calls them.

The spacer reserving vertical space for the carousel (custom_minimum_size = Vector2(0, 290)) is unchanged — it accounts for the carousel’s own height, not the footer, so it stays exactly as-is regardless of how many footer buttons used to render below it.

4. Hint text keys off item count, not has_progressed

Section titled “4. Hint text keys off item count, not has_progressed”

Today: has_progressed ? "swipe to choose" copy : "press to begin" copy (the single-Survival-card first-launch case). Since Ship is now always a second item, a first-launch player already has something to browse. Replace the condition with _items.size() > 1:

if _items.size() > 1:
hint.text = "Tap a card to select" if is_touch else "Swipe / stick to browse · click / Ⓐ to select"
else:
hint.text = "Tap to begin" if is_touch else "Press Ⓐ to begin"

The else branch is now effectively dead in practice (Ship is unconditional, so _items.size() is never 1) but is kept rather than deleted — it’s the correct fallback if a future change ever made Ship conditional too, and _compute_slots/the carousel already support a genuine 1-item state (tested), so there’s no reason to special-case it away.

Existing tests that assert the OLD separation must flip to assert the NEW integration:

  • test_footer_buttons_are_not_part_of_the_mode_carousel — deleted; the invariant it checked (footer buttons never centered/paged as if they were modes) is now false by design. Replaced by test_action_cards_are_part_of_the_carousel: with has_progressed = true, assert _cards/_items contains an item with id == "shop"/"bestiary"/"ship_config" and kind == "action".
  • test_shop_entry_emits_shop_requested — rewritten to page the carousel to the SHOP card (_jump_to to its index, or step via _carousel_step until centered) and confirm it (call _activate or synthesize the ui_accept input), asserting shop_requested fires and mode_chosen does NOT. The old _find_by_mode recursive scene-tree search helper is deleted (no longer meaningful — action buttons aren’t separate scene-tree nodes hunted outside _cards, they’re carousel cards like any other).
  • test_confirm_activates_the_centered_mode — kept as-is; still exercises the kind == "mode" path and still expects mode_chosen with "survival" (index 0 is still the sole mode card under V01_CRYSTALS_ONLY).
  • New: test_ship_card_present_on_first_launch — has_progressed = false, assert _items has exactly 2 entries (survival, ship_config) and no shop/bestiary entries.
  • New: test_confirming_an_action_card_does_not_hide_the_menu — page to the SHIP card, confirm it, assert visible is still true and mode_chosen did NOT fire.
  • New: test_remote_card_gated_by_dev_tools — cannot toggle BuildConfig.dev_tools() from a test (it’s a build-time constant), so instead assert consistency: if BuildConfig.dev_tools() is true in the test environment, _items contains a "remote" action entry; if false, it doesn’t. (Godot test runs are dev builds, so this will exercise the true branch in practice — the assertion is written to hold either way rather than assuming.)

Render/UI-only change to one file (ui/start_menu.gd) plus its test file — no /sim files touched, determinism baseline unaffected by construction. main.gd is unchanged (same signals, same listeners). Given the menu is seen identically on iPhone/iPad/Apple TV/Mac and the original carousel needed a real on-device pass to catch a centering bug, include a real-device check (Mac export at minimum) before calling this done.