Start Menu Carousel Declutter — design
Start Menu Carousel Declutter — design
Section titled “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.
Context
Section titled “Context”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).
Non-goals
Section titled “Non-goals”- 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 forship_config_requested/shop_requested/bestiary_requested/remote_requestedand hides/restoresstart_menu.visiblearound each; those signals and that flow are reused verbatim, only what triggers them moves from a footerButton.pressedto a carousel card confirm. - No change to
V01_CRYSTALS_ONLY,has_progressed, orBuildConfig.dev_tools()gating — same three gates, same conditions, just relocated. - No change to
ShipConfigPanel,MetaShopPanel, or the codex/bestiary overlay themselves.
Design
Section titled “Design”1. Carousel items gain a kind
Section titled “1. Carousel items gain a kind”_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"]) # alwaysif 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.
3. Deleted: the footer-button block and its now-dead helpers
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.
Testing
Section titled “Testing”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 bytest_action_cards_are_part_of_the_carousel: withhas_progressed = true, assert_cards/_itemscontains an item withid == "shop"/"bestiary"/"ship_config"andkind == "action".test_shop_entry_emits_shop_requested— rewritten to page the carousel to the SHOP card (_jump_toto its index, or step via_carousel_stepuntil centered) and confirm it (call_activateor synthesize theui_acceptinput), assertingshop_requestedfires andmode_chosendoes NOT. The old_find_by_moderecursive 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 thekind == "mode"path and still expectsmode_chosenwith"survival"(index 0 is still the sole mode card underV01_CRYSTALS_ONLY).- New:
test_ship_card_present_on_first_launch—has_progressed = false, assert_itemshas exactly 2 entries (survival,ship_config) and noshop/bestiaryentries. - New:
test_confirming_an_action_card_does_not_hide_the_menu— page to the SHIP card, confirm it, assertvisibleis stilltrueandmode_chosendid NOT fire. - New:
test_remote_card_gated_by_dev_tools— cannot toggleBuildConfig.dev_tools()from a test (it’s a build-time constant), so instead assert consistency: ifBuildConfig.dev_tools()is true in the test environment,_itemscontains a"remote"action entry; if false, it doesn’t. (Godot test runs are dev builds, so this will exercise thetruebranch in practice — the assertion is written to hold either way rather than assuming.)
Rollout
Section titled “Rollout”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.