Skip to content

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.

  • Ship is ALWAYS a carousel item (no gate) — matches today’s unconditional footer button.
  • Shop and Enemies (bestiary) carousel items only appear when has_progressed is 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 for ship_config_requested/shop_requested/bestiary_requested/remote_requested and owns hiding/restoring start_menu.visible around 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 /sim files 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_carousel center-card press), :455-457 (_complete_drag center-card press), :552-558 (_input confirm branch), :583-585 (_choose → _activate)
  • Modify: tests/test_start_menu.gd:30,53,70,83,143,156,175 (hand-rolled _items literals 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/_input already call _activate generically after this task). card.get_meta("kind", "mode") — every card built by _make_mode_card now 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 _items type comment

In ui/start_menu.gd, find (line 55):

var _items: Array = [] # [id, title, desc, accent] per mode

Replace 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 _choose with _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()
return

Replace 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 _items literal 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 _activate dispatch 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
Terminal window
git add ui/start_menu.gd tests/test_start_menu.gd
git 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, emits
the same signal a footer button would). No action cards exist yet -- this
lands the dispatch mechanism only, wired into _ready() in the next commit."

Section titled “Task 2: Fold Ship/Shop/Enemies/Remote Control into the carousel”

Files:

  • Modify: ui/start_menu.gd:43-45 (comment), :125-133 (_items extension), :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 — delete test_footer_buttons_are_not_part_of_the_mode_carousel (lines 100-106), delete _find_by_mode (lines 123-132), rewrite test_shop_entry_emits_shop_requested (lines 108-121), rewrite test_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_requested to 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_progressed doc 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 := false

Replace 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 _items in _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_STAGGER

The 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_btn and _card_box helpers

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 b

Find 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
Terminal window
git add ui/start_menu.gd tests/test_start_menu.gd
git 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 only
interactive element on the main menu. Ship is always offered; Shop/Enemies
join once has_progressed; Remote Control only in dev builds. Same four
signals as before (main.gd unchanged), just triggered by a carousel card
confirm 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.