Start Menu Carousel Declutter Implementation Plan
Start Menu Carousel Declutter Implementation Plan
Section titled “Start Menu Carousel Declutter 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: Fold the Ship/Shop/Enemies/Remote Control footer buttons on the main menu into the mode carousel as additional “action” cards, so the carousel is the only interactive element on the screen.
Architecture: ui/start_menu.gd‘s _items array (currently [id, title, desc, accent] per mode card) grows a 5th kind field ("mode" | "action"). A new _activate(id, kind) replaces _choose(mode): kind == "mode" reproduces today’s exact “hide menu, emit mode_chosen” behavior; kind == "action" emits the matching existing signal (ship_config_requested/shop_requested/bestiary_requested/remote_requested) without hiding the menu, exactly mirroring what the footer buttons’ pressed handlers do today. main.gd is untouched — same four signals, same four listeners.
Tech Stack: Godot 4.6.3 / GDScript, GUT 9.6.0 test framework.
Global Constraints
Section titled “Global Constraints”- Ship is ALWAYS a carousel item (no gate) — matches today’s unconditional footer button.
- Shop and Enemies (
bestiary) carousel items only appear whenhas_progressedis true (meta.banked_gold > 0 and meta.seen_enemies.size() > 0) — matches today’s gate exactly. - Remote Control carousel item only appears when
BuildConfig.dev_tools()is true — matches today’s gate exactly. - No changes to
main.gd— it already listens forship_config_requested/shop_requested/bestiary_requested/remote_requestedand owns hiding/restoringstart_menu.visiblearound each; reuse verbatim. - No changes to carousel paging/drag/reveal mechanics or hologram-bracket visuals (
CENTER_SCALE/SIDE_SCALE/SIDE_ALPHA/etc constants untouched). - No
/simfiles touched; determinism baseline is unaffected by construction (render/UI-only change). - Test runner:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit(narrows to this file; exit 0 = pass). Full suite guard before finishing:godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit.
Task 1: _activate(id, kind) dispatch mechanism
Section titled “Task 1: _activate(id, kind) dispatch mechanism”Files:
- Modify:
ui/start_menu.gd:55(comment),:117-124(mode literals),:238-244(_make_mode_card),:324-326(_render_carouselcenter-card press),:455-457(_complete_dragcenter-card press),:552-558(_inputconfirm branch),:583-585(_choose→_activate) - Modify:
tests/test_start_menu.gd:30,53,70,83,143,156,175(hand-rolled_itemsliterals gain a 5th field), plus new tests appended at end of file
Interfaces:
- Consumes: nothing new from outside this file.
- Produces:
_activate(id: String, kind: String) -> void— called by Task 2’s action-card confirm wiring (no changes needed there since_render_carousel/_complete_drag/_inputalready call_activategenerically after this task).card.get_meta("kind", "mode")— every card built by_make_mode_cardnow carries this meta, read by the 3 confirm call sites.
This task makes _make_mode_card require every _items entry to have exactly 5 elements (item[4] = kind). It does NOT yet add any action items to _ready()’s _items (that’s Task 2) — it only tags the existing mode entries with "mode" and fixes every test that hand-builds an _items array so _make_mode_card doesn’t crash on a missing index.
- Step 1: Update the
_itemstype comment
In ui/start_menu.gd, find (line 55):
var _items: Array = [] # [id, title, desc, accent] per modeReplace with:
var _items: Array = [] # [id, title, desc, accent, kind] -- kind is "mode" (starts a # run) or "action" (opens a panel: Ship/Shop/Enemies/Remote)- Step 2: Tag the existing mode literals with
"mode"
In ui/start_menu.gd, find (lines 116-124):
var modes := [ ["tutorial", "TUTORIAL", "Learn the ropes, guided and paused", Color(0.5, 0.95, 0.62)], ["story", "STORY", "Ascend the Spectrum, the campaign", Color(1.0, 0.62, 0.32)], ["survival", "SURVIVAL", "Harvest element crystals, build-craft", Color(0.72, 0.42, 1.0)], ] # v0.1 App Store launch ships ONLY Survival. The other modes are built + tested but # gated off the launch menu; flip V01_CRYSTALS_ONLY to false to restore the full mode picker. if V01_CRYSTALS_ONLY: modes = [["survival", "SURVIVAL", "Endless waves, build your run", NeonTheme.CYAN]]Replace with:
var modes := [ ["tutorial", "TUTORIAL", "Learn the ropes, guided and paused", Color(0.5, 0.95, 0.62), "mode"], ["story", "STORY", "Ascend the Spectrum, the campaign", Color(1.0, 0.62, 0.32), "mode"], ["survival", "SURVIVAL", "Harvest element crystals, build-craft", Color(0.72, 0.42, 1.0), "mode"], ] # v0.1 App Store launch ships ONLY Survival. The other modes are built + tested but # gated off the launch menu; flip V01_CRYSTALS_ONLY to false to restore the full mode picker. if V01_CRYSTALS_ONLY: modes = [["survival", "SURVIVAL", "Endless waves, build your run", NeonTheme.CYAN, "mode"]]- Step 3: Tag cards with their kind in
_make_mode_card
In ui/start_menu.gd, find (lines 238-244):
func _make_mode_card(item: Array) -> Button: var id: String = item[0] var title: String = item[1] var desc: String = item[2] var accent: Color = item[3] var card := Button.new() card.set_meta("mode", id)Replace with:
func _make_mode_card(item: Array) -> Button: var id: String = item[0] var title: String = item[1] var desc: String = item[2] var accent: Color = item[3] var kind: String = item[4] var card := Button.new() card.set_meta("mode", id) card.set_meta("kind", kind)- Step 4: Replace
_choosewith_activate
In ui/start_menu.gd, find (lines 583-585):
func _choose(mode: String) -> void: visible = false mode_chosen.emit(mode)Replace with:
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()- Step 5: Update the 3 call sites that called
_choose
In ui/start_menu.gd, find (lines 324-326, inside _render_carousel):
if is_center: card.grab_focus.call_deferred() card.pressed.connect(func() -> void: _ui_select(); _choose(String(card.get_meta("mode"))))Replace with:
if is_center: card.grab_focus.call_deferred() card.pressed.connect(func() -> void: _ui_select(); _activate(String(card.get_meta("mode")), String(card.get_meta("kind"))))Find (lines 455-457, inside _complete_drag):
if is_center: c.grab_focus.call_deferred() c.pressed.connect(func() -> void: _choose(String(c.get_meta("mode"))))Replace with:
if is_center: c.grab_focus.call_deferred() c.pressed.connect(func() -> void: _activate(String(c.get_meta("mode")), String(c.get_meta("kind"))))Find (lines 552-558, inside _input):
if confirm: _ui_select() # _cards[0] is always the centered card -- _compute_slots() puts the center slot first. # It can only ever be a mode-carousel card (survival/tutorial/story): footer # buttons (shop/bestiary/ship_config/remote) stopped being appended to _cards back in # Task 3 and are activated via their own `pressed` signal connections instead. _choose(String(_cards[0].get_meta("mode", "survival"))) get_viewport().set_input_as_handled() returnReplace with:
if confirm: _ui_select() # _cards[0] is always the centered card -- _compute_slots() puts the center slot first. # It can be either a mode card (starts a run) or an action card (Ship/Shop/Enemies/ # Remote Control) -- _activate() dispatches on the card's "kind" meta. var center := _cards[0] _activate(String(center.get_meta("mode", "survival")), String(center.get_meta("kind", "mode"))) get_viewport().set_input_as_handled() return- Step 6: Fix every hand-rolled
_itemsliteral in the test file to carry a 5th field
_make_mode_card now reads item[4] unconditionally — any test that builds its own _items array with 4-element tuples and then calls a method that builds cards (_render_carousel, _jump_to, _begin_drag/_update_drag) will crash with an out-of-bounds index error. Fix all 7 occurrences in tests/test_start_menu.gd:
Line 30:
m._items = [["a", "A", "desc a", Color.RED], ["b", "B", "desc b", Color.GREEN], ["c", "C", "desc c", Color.BLUE]]→
m._items = [["a", "A", "desc a", Color.RED, "mode"], ["b", "B", "desc b", Color.GREEN, "mode"], ["c", "C", "desc c", Color.BLUE, "mode"]]Line 53:
m._items = [["a", "A", "d", Color.RED], ["b", "B", "d", Color.GREEN], ["c", "C", "d", Color.BLUE]]→
m._items = [["a", "A", "d", Color.RED, "mode"], ["b", "B", "d", Color.GREEN, "mode"], ["c", "C", "d", Color.BLUE, "mode"]]Line 70:
m._items = [["a", "A", "d", Color.RED], ["b", "B", "d", Color.GREEN]]→
m._items = [["a", "A", "d", Color.RED, "mode"], ["b", "B", "d", Color.GREEN, "mode"]]Line 83:
m._items = [["a","A","d",Color.RED], ["b","B","d",Color.GREEN], ["c","C","d",Color.BLUE]]→
m._items = [["a","A","d",Color.RED,"mode"], ["b","B","d",Color.GREEN,"mode"], ["c","C","d",Color.BLUE,"mode"]]Line 143:
m._items = [["a","A","d",Color.RED], ["b","B","d",Color.GREEN], ["c","C","d",Color.BLUE], ["d","D","d",Color.YELLOW]]→
m._items = [["a","A","d",Color.RED,"mode"], ["b","B","d",Color.GREEN,"mode"], ["c","C","d",Color.BLUE,"mode"], ["d","D","d",Color.YELLOW,"mode"]]Line 156:
m._items = [["a","A","d",Color.RED], ["b","B","d",Color.GREEN], ["c","C","d",Color.BLUE]]→
m._items = [["a","A","d",Color.RED,"mode"], ["b","B","d",Color.GREEN,"mode"], ["c","C","d",Color.BLUE,"mode"]]Line 175:
m._items = [["a","A","d",Color.RED], ["b","B","d",Color.GREEN], ["c","C","d",Color.BLUE]]→
m._items = [["a","A","d",Color.RED,"mode"], ["b","B","d",Color.GREEN,"mode"], ["c","C","d",Color.BLUE,"mode"]]- Step 7: Run the existing suite to confirm nothing broke
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit
Expected: all tests that existed before this task still pass (Godot’s headless runner may SIGABRT at engine teardown even on a fully green suite — read the printed “Run Summary” pass/fail counts, not the process exit code, per this project’s known gotcha).
- Step 8: Write the new
_activatedispatch tests
Append to tests/test_start_menu.gd:
# ── _activate(id, kind) dispatch (Task 1) ───────────────────────────────────────
func test_activate_action_kind_emits_signal_and_keeps_menu_visible() -> void: var m := StartMenu.new() add_child_autofree(m) await get_tree().process_frame watch_signals(m) m._activate("shop", "action") assert_signal_emitted(m, "shop_requested") assert_signal_not_emitted(m, "mode_chosen") assert_true(m.visible, "action cards must not hide the menu")
func test_activate_remote_action_emits_remote_requested() -> void: var m := StartMenu.new() add_child_autofree(m) await get_tree().process_frame watch_signals(m) m._activate("remote", "action") assert_signal_emitted(m, "remote_requested") assert_true(m.visible, "action cards must not hide the menu")
func test_activate_mode_kind_hides_menu_and_emits_mode_chosen() -> void: var m := StartMenu.new() add_child_autofree(m) await get_tree().process_frame watch_signals(m) m._activate("survival", "mode") assert_signal_emitted_with_parameters(m, "mode_chosen", ["survival"]) assert_false(m.visible, "mode cards hide the menu, same as today")- Step 9: Run the new tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit
Expected: all tests pass, including the 3 new ones from Step 8.
- Step 10: Commit
git add ui/start_menu.gd tests/test_start_menu.gdgit commit -m "refactor(ui): start_menu cards dispatch via _activate(id, kind), not _choose(mode)
Generalizes the carousel's confirm path so a card can either start a run(kind=mode, today's exact behavior) or open a panel (kind=action, emitsthe same signal a footer button would). No action cards exist yet -- thislands the dispatch mechanism only, wired into _ready() in the next commit."Task 2: Fold Ship/Shop/Enemies/Remote Control into the carousel
Section titled “Task 2: Fold Ship/Shop/Enemies/Remote Control into the carousel”Files:
- Modify:
ui/start_menu.gd:43-45(comment),:125-133(_itemsextension),:139-170(delete footer-button block),:180-184(hint text),:510-522(delete_footer_btn),:596-604(delete_card_box) - Modify:
tests/test_start_menu.gd— deletetest_footer_buttons_are_not_part_of_the_mode_carousel(lines 100-106), delete_find_by_mode(lines 123-132), rewritetest_shop_entry_emits_shop_requested(lines 108-121), rewritetest_mode_carousel_centers_one_card_when_only_one_mode(lines 11-23), add new tests
Interfaces:
-
Consumes:
_activate(id, kind)from Task 1 (already wired into every confirm path — no further changes needed there). -
Produces: nothing new consumed by later tasks (this is the last task).
-
Step 1: Delete the tests that assert the OLD footer/carousel separation
In tests/test_start_menu.gd, delete test_footer_buttons_are_not_part_of_the_mode_carousel entirely (lines 100-106):
func test_footer_buttons_are_not_part_of_the_mode_carousel() -> void: var m := StartMenu.new(); m.has_progressed = true add_child_autofree(m) await get_tree().process_frame for c in m._cards: assert_false(String(c.get_meta("mode", "")) in ["shop", "bestiary", "ship_config", "remote"], "footer buttons must not be centered/paged as if they were modes")This invariant is now false by design — action cards ARE meant to be centered/paged like any other carousel card.
Delete the _find_by_mode helper entirely (lines 123-132):
# Recursive meta-tagged node search, used above since footer buttons are real scene-tree# nodes but are no longer tracked in the flat _cards array.func _find_by_mode(node: Node, mode: String) -> Button: if node is Button and String(node.get_meta("mode", "")) == mode: return node for c in node.get_children(): var found := _find_by_mode(c, mode) if found != null: return found return null- Step 2: Rewrite
test_shop_entry_emits_shop_requestedto page the carousel
In tests/test_start_menu.gd, find (lines 108-121):
func test_shop_entry_emits_shop_requested() -> void: var m := StartMenu.new(); m.has_progressed = true # Shop appears only after progress add_child_autofree(m); await get_tree().process_frame watch_signals(m) # Footer buttons are no longer tracked in _cards (Task 3) -- find the real SHOP button in # the scene tree and press IT, so this still exercises the actual .pressed wiring instead # of just asserting the panel's own signal fires when emitted directly (which would pass # even if the button were disconnected). var shop_btn := _find_by_mode(m, "shop") assert_not_null(shop_btn, "a SHOP footer button exists in the scene tree") if shop_btn == null: return # fail loudly for the assertion above, not an incidental null-deref crash below shop_btn.emit_signal("pressed") assert_signal_emitted(m, "shop_requested")Replace with:
func test_shop_entry_emits_shop_requested() -> void: var m := StartMenu.new(); m.has_progressed = true # Shop appears only after progress add_child_autofree(m); await get_tree().process_frame watch_signals(m) # SHOP is now a carousel card, not a separate footer button -- page to it and confirm it # exactly as a player would (carousel step + ui_accept). var shop_idx := -1 for i in m._items.size(): if String(m._items[i][0]) == "shop": shop_idx = i break assert_true(shop_idx >= 0, "a SHOP action card exists once has_progressed is true") m._jump_to(shop_idx) var confirm := InputEventAction.new() confirm.action = "ui_accept" confirm.pressed = true m._input(confirm) assert_signal_emitted(m, "shop_requested") assert_signal_not_emitted(m, "mode_chosen")- Step 3: Rewrite the single-card test for the new 2-card default
In tests/test_start_menu.gd, find (lines 11-23):
func test_mode_carousel_centers_one_card_when_only_one_mode() -> void: var m := StartMenu.new() add_child_autofree(m) await get_tree().process_frame # _ready() has run, carousel initialized with the one mode (V01_CRYSTALS_ONLY=true). # Every reveal-tracked node (including carousel cards) starts at alpha 0 and eases in # via _process -- fast-forward past REVEAL_DUR before asserting the settled alpha. m._process(1.0) # _carousel_root contains only carousel cards (ships/footer are in separate containers) var carousel_cards := m._carousel_root.get_children() assert_eq(carousel_cards.size(), 1, "V01_CRYSTALS_ONLY=true -> a single centered mode card") assert_almost_eq(carousel_cards[0].scale.x, StartMenu.CENTER_SCALE, 0.01) assert_almost_eq(carousel_cards[0].modulate.a, 1.0, 0.01)Replace with:
func test_default_ready_state_shows_survival_and_ship_two_cards() -> void: var m := StartMenu.new() add_child_autofree(m) await get_tree().process_frame # _ready() has run: V01_CRYSTALS_ONLY=true -> one mode card, plus Ship is always offered # (has_progressed defaults false, so Shop/Enemies don't join yet) -> 2 items total. # Every reveal-tracked node (including carousel cards) starts at alpha 0 and eases in # via _process -- fast-forward past REVEAL_DUR before asserting the settled alpha. m._process(1.0) assert_eq(m._items.size(), 2, "Survival + Ship on a fresh/first-launch instance") assert_eq(String(m._items[0][0]), "survival") assert_eq(String(m._items[1][0]), "ship_config") var carousel_cards := m._carousel_root.get_children() assert_eq(carousel_cards.size(), 2, "center + the one side neighbour (2-item case)") assert_almost_eq(carousel_cards[0].scale.x, StartMenu.CENTER_SCALE, 0.01) assert_almost_eq(carousel_cards[0].modulate.a, 1.0, 0.01)
func test_compute_slots_one_item_shows_only_center() -> void: var m := StartMenu.new() m._items = [["a", "A", "d", Color.RED, "mode"]] var slots := m._compute_slots(0, 100.0) assert_eq(slots.size(), 1, "a single item renders center-only, no side neighbours") assert_eq(int(slots[0][0]), 0) assert_true(bool(slots[0][4]), "the lone slot is marked as the center")(The single-item structural case, no longer reachable through _ready() now that Ship is
always present, stays covered directly via _compute_slots.)
- Step 4: Add coverage for the gating rules
Append to tests/test_start_menu.gd:
# ── Action cards folded into the carousel (Task 2) ──────────────────────────────
func test_ship_card_present_on_first_launch() -> void: var m := StartMenu.new(); m.has_progressed = false add_child_autofree(m) await get_tree().process_frame var ids: Array = [] for item in m._items: ids.append(String(item[0])) assert_eq(ids, ["survival", "ship_config"], "first launch: only Survival + Ship, nothing to shop for/browse yet")
func test_action_cards_join_once_progressed() -> void: var m := StartMenu.new(); m.has_progressed = true add_child_autofree(m) await get_tree().process_frame var ids: Array = [] for item in m._items: ids.append(String(item[0])) assert_true(ids.has("shop"), "SHOP card joins once has_progressed is true") assert_true(ids.has("bestiary"), "ENEMIES card joins once has_progressed is true")
func test_remote_card_gated_by_dev_tools() -> void: var m := StartMenu.new() add_child_autofree(m) await get_tree().process_frame var has_remote := false for item in m._items: if String(item[0]) == "remote": has_remote = true assert_eq(has_remote, BuildConfig.dev_tools(), "REMOTE card presence must track BuildConfig.dev_tools() exactly")
func test_confirming_an_action_card_does_not_hide_the_menu() -> void: var m := StartMenu.new(); m.has_progressed = false # items: [survival, ship_config] add_child_autofree(m) await get_tree().process_frame watch_signals(m) m._jump_to(1) # ship_config is index 1 var confirm := InputEventAction.new() confirm.action = "ui_accept" confirm.pressed = true m._input(confirm) assert_signal_emitted(m, "ship_config_requested") assert_signal_not_emitted(m, "mode_chosen") assert_true(m.visible, "action cards must not hide the menu")- Step 5: Run the suite to see the new/changed tests fail correctly
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit
Expected: FAIL — test_default_ready_state_shows_survival_and_ship_two_cards,
test_shop_entry_emits_shop_requested, test_ship_card_present_on_first_launch,
test_action_cards_join_once_progressed, test_remote_card_gated_by_dev_tools, and
test_confirming_an_action_card_does_not_hide_the_menu all fail because ui/start_menu.gd
doesn’t build any action items yet — _items is still just the mode list. Everything else
(including the untouched test_compute_slots_one_item_shows_only_center, which doesn’t
depend on _ready()) still passes.
- Step 6: Update the
has_progresseddoc comment
In ui/start_menu.gd, find (lines 43-45):
# Set by main before add_child: true once the player has played (banked gold) + seen an enemy.# First launch (false) shows ONLY the Survival button — no Shop/Enemies footer, no swipe hint.var has_progressed := falseReplace with:
# Set by main before add_child: true once the player has played (banked gold) + seen an enemy.# First launch (false) shows the carousel with just Survival + Ship -- Shop/Enemies cards only# join once there's actually something to shop for or browse.var has_progressed := false- Step 7: Append action items to
_itemsin_ready()
In ui/start_menu.gd, find (lines 125-133):
_items = modes _carousel_root = Control.new() _carousel_root.set_anchors_preset(Control.PRESET_FULL_RECT) _carousel_root.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_carousel_root) # sibling of `center`, NOT inside the VBox -- _center() below # returns absolute viewport coords, same reasoning as the shop's # _carousel_root (see meta_shop_panel.gd's own comment on this). _carousel_index = 0 _render_carousel(0)Replace with:
_items = modes # Ship/Shop/Enemies/Remote Control fold into the SAME carousel as extra "action" cards # instead of separate footer buttons -- confirming one emits its existing signal instead of # starting a run (see _activate()). Same three gates as the old footer buttons: Ship is # always offered; Shop/Enemies need a played run (has_progressed); Remote Control is dev-only. _items.append(["ship_config", "SHIP", "Choose your hull, view loadout", Color(0.72, 0.45, 1.0), "action"]) 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"]) _carousel_root = Control.new() _carousel_root.set_anchors_preset(Control.PRESET_FULL_RECT) _carousel_root.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_carousel_root) # sibling of `center`, NOT inside the VBox -- _center() below # returns absolute viewport coords, same reasoning as the shop's # _carousel_root (see meta_shop_panel.gd's own comment on this). _carousel_index = 0 _render_carousel(0)- Step 8: Delete the footer-button block
In ui/start_menu.gd, find and delete entirely (lines 139-170):
# Ship config — always visible (ship selection now lives entirely in ShipConfigPanel, # reached via this button), unlike Shop/Enemies which need at least one played run to have # content. var ship_cfg_btn := _footer_btn("Ship", "ship_config", Color(0.72, 0.45, 1.0), 44, NeonTheme.title_font(), 19) ship_cfg_btn.pressed.connect(func() -> void: _ui_select(); ship_config_requested.emit()) box.add_child(ship_cfg_btn) _reveal.append({"node": ship_cfg_btn, "delay": d}) d += REVEAL_STAGGER
# Shop + Enemies footer — only once the player has played a run (has gold + seen an enemy). # On first launch there's nothing to shop for or browse, so keep it to just the Survival button. if has_progressed: var shop_btn := _footer_btn("Shop", "shop", Color(1.0, 0.84, 0.3), 44, NeonTheme.title_font(), 19) shop_btn.pressed.connect(func() -> void: _ui_select(); shop_requested.emit()) box.add_child(shop_btn) _reveal.append({"node": shop_btn, "delay": d}) d += REVEAL_STAGGER
# Enemies footer button — browse the enemy codex between runs. var codex_btn := _footer_btn("Enemies", "bestiary", Color(0.5, 0.85, 1.0), 44, NeonTheme.mono_font(), 16) codex_btn.pressed.connect(func() -> void: _ui_select(); bestiary_requested.emit()) box.add_child(codex_btn) _reveal.append({"node": codex_btn, "delay": d}) d += REVEAL_STAGGER
# Remote Control is a DEV tool (the playtest console) — hidden on release/App Store builds. if BuildConfig.dev_tools(): var remote_btn := _footer_btn("Remote Control", "remote", Color(0.6, 0.62, 0.7), 34, NeonTheme.mono_font(), 14) remote_btn.pressed.connect(func() -> void: _ui_select(); _on_remote_requested()) box.add_child(remote_btn) _reveal.append({"node": remote_btn, "delay": d}) d += REVEAL_STAGGERThe var d := REVEAL_STAGGER * 2.0 / for card in _cards: ... loop directly above this block (already present, untouched by Step 7) still runs and still increments d — after this deletion, the very next code in the file is the hint_spacer/hint construction, which now uses whatever d the card-reveal loop left it at.
- Step 9: Update the hint text to key off item count
In ui/start_menu.gd, find (lines 180-184):
# First launch = one button, so no "choose"/swipe language — just how to begin. if has_progressed: hint.text = "Tap a mode to begin" if is_touch else "Swipe / stick to choose · click / Ⓐ to select" else: hint.text = "Tap to begin" if is_touch else "Press Ⓐ to begin"Replace with:
# Ship is always a second item now, so a fresh install already has something to browse; # the single-item ("Tap to begin") case is a defensive fallback, not the normal path. 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"- Step 10: Delete the now-dead
_footer_btnand_card_boxhelpers
In ui/start_menu.gd, find and delete entirely (lines 510-522):
func _footer_btn(txt: String, mode: String, accent: Color, h: float, font: Font, fs: int) -> Button: var b := Button.new() b.text = txt b.focus_mode = Control.FOCUS_ALL b.add_theme_font_override("font", font) b.add_theme_font_size_override("font_size", fs) b.add_theme_stylebox_override("normal", _card_box(accent, 0.07)) b.add_theme_stylebox_override("hover", _card_box(accent, 0.2)) b.add_theme_stylebox_override("pressed", _card_box(accent, 0.28)) b.add_theme_stylebox_override("focus", _card_box(accent, 0.2)) b.custom_minimum_size = Vector2(CARD_W, h) b.set_meta("mode", mode) return bFind and delete entirely (lines 596-604):
func _card_box(accent: Color, fill: float) -> StyleBoxFlat: var sb := StyleBoxFlat.new() sb.bg_color = Color(0.04, 0.05, 0.09, 0.94).lerp(Color(accent.r, accent.g, accent.b, 0.94), fill) sb.set_border_width_all(2) sb.border_color = Color(accent.r, accent.g, accent.b, 0.85) sb.set_corner_radius_all(14) sb.shadow_color = Color(accent.r, accent.g, accent.b, 0.35) sb.shadow_size = 8 return sb- Step 11: Run the full test_start_menu suite to verify everything passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit
Expected: all tests pass (both Task 1’s and Task 2’s).
- Step 12: Run the full project suite as a regression guard
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: same script/test counts as before this plan started, all passing (ignore a
teardown SIGABRT after a green “Run Summary” — known Godot headless-exit gotcha, not a
failure). Confirms no other file (e.g. main.gd, which listens to the same four
start_menu signals) regressed.
- Step 13: Commit
git add ui/start_menu.gd tests/test_start_menu.gdgit commit -m "feat(ui): fold Ship/Shop/Enemies/Remote Control into the start menu carousel
Removes the separate footer-button row -- the carousel is now the onlyinteractive element on the main menu. Ship is always offered; Shop/Enemiesjoin once has_progressed; Remote Control only in dev builds. Same foursignals as before (main.gd unchanged), just triggered by a carousel cardconfirm instead of a footer Button.pressed."- Step 14: Real-device check
Export a Mac build (godot --headless --path . --export-release "macOS" builds/macos/BulletHeaven.app) and open it. Confirm: the main menu shows only the carousel (no button row below it); swiping/stepping through it reaches SHIP and, after playing one run, SHOP and ENEMIES; confirming SHIP opens ShipConfigPanel and returns to the menu on close without the menu having visibly “started” anything; confirming SURVIVAL still starts a run. This mirrors the original 2026-07-05 carousel design’s own rollout note that this screen needs a real on-device pass, not just headless tests.