Skip to content

Unified Menu Carousel — design

Fold the start menu’s remaining footer buttons (Ship, Shop, Enemies) into the same hologram-bracket carousel the mode picker already uses, so every top-level menu choice is navigated identically — and, in doing so, separate what the menu contains from how it’s drawn, so a future switch to a different visual style touches one layer, not both.

ui/start_menu.gd (from the 2026-07-05 Start Menu Carousel work) currently shows a hologram-bracket carousel of mode cards (Tutorial/Story/Survival/Crystals, collapsed to just Survival while V01_CRYSTALS_ONLY = true), followed by a VBoxContainer column of static footer Buttons: “Ship” (always), “Shop”/“Enemies” (once has_progressed), and “Remote Control” (dev builds only, BuildConfig.dev_tools()). Confirming a carousel card calls _choose(mode) — hardcoded to “launch this mode,” read via _cards[0].get_meta("mode"). The footer buttons are separate nodes with their own pressed signal connections (ship_config_requested.emit(), shop_requested.emit(), bestiary_requested.emit()), entirely outside the carousel’s _items/_cards machinery.

  • Remote Control stays a separate dev-only footer button below the carousel — it’s a developer tool, not a real player-facing menu choice, so it doesn’t need the same hologram-card treatment or need to displace real menu items in the carousel’s card count.
  • No change to ShopCategories, MetaState, ShipBonuses, ui/meta_shop_panel.gd, or any upgrade/loadout data model.
  • No change to ShipConfigPanel’s own ship carousel (paging/confirm/touch, shipped 2026-07-05/06) — reached the same way, via a “Ship” item’s on_confirm.
  • No change to which modes exist or V01_CRYSTALS_ONLY’s gating of them — only how many navigation levels exist above them (see Design §2) and how the centered card’s confirm action is looked up (Design §3).
  • Not building a generic, speculative “menu system” usable by arbitrary future screens beyond this one carousel — the content/view split below is scoped to StartMenu specifically, matching this codebase’s existing bias toward one-off ports over speculative shared abstractions (the same reasoning the original carousel design used to justify not extracting a shared carousel class from the shop/mode/ship ports).

1. Content model — MenuItem, a plain Dictionary

Section titled “1. Content model — MenuItem, a plain Dictionary”

A menu item is {id: String, label: String, desc: String, color: Color, on_confirm: Callable}. on_confirm takes no arguments and returns nothing — it’s a closure the content layer defines (bind whatever it needs, e.g. func() -> void: _choose("crystals") or func() -> void: ship_config_requested.emit()). The carousel view (hologram brackets, slot math, drag, tween, MenuNav stepping — all of _render_carousel, _make_card (renamed from _make_mode_card), _compute_slots, _place_card, _begin_drag/_update_drag/_end_drag, _carousel_step) never inspects id or branches on item type. It reads label/desc/color to draw a card and calls item.on_confirm.call() when that card is confirmed. This is the actual separation this design is for: swapping the view layer later (a grid, a different card style, whatever) means rewriting the render/layout functions against the same Array[Dictionary] shape, not touching what the menu contains or what each entry does.

_items’s element type changes from the current [id, label, desc, color] Array-of-Arrays to Array[Dictionary] of this shape. _make_card’s only remaining per-item logic is reading label/desc/color/an id-derived accent-dot styling already keyed off color — no behavioral branching.

Section titled “2. Two navigation levels: top-level menu, and a “Play” sub-carousel of modes”

The top-level list (_build_top_level_items() -> Array[Dictionary]) is: Play (always), Ship (always), Shop (has_progressed), Enemies (has_progressed) — same has_progressed gating the footer buttons use today, just relocated into which items exist rather than which buttons are built.

  • Ship / Shop / Enemies: on_confirm is exactly today’s footer-button behavior — func() -> void: _ui_select(); ship_config_requested.emit() (etc.) — emitting the same signals main.gd already listens for. These items never drill into a sub-carousel.
  • Play: on_confirm is _activate_play(). This builds the mode list (today’s modes array — Tutorial/Story/Survival/Crystals, or just the one Survival/Crystals entry under V01_CRYSTALS_ONLY) as Array[Dictionary] in the same MenuItem shape, each entry’s on_confirm calling _choose(mode_id) exactly as today. If that list has more than one entry, _activate_play() swaps _items to it, remembers the top-level list to return to (_parent_items: Array[Dictionary], empty when at the top level), and re-renders centered on the first mode. If it has exactly one entry (today’s V01_CRYSTALS_ONLY state), _activate_play() skips the swap entirely and calls that single mode’s on_confirm.call() directly — Play launches Survival immediately, no extra confirm press, matching today’s actual play-feel. Once more modes ship (V01_CRYSTALS_ONLY flips), Play’s behavior changes automatically to “drill in,” no code change needed at that point.

_parent_items.is_empty() is exactly the signal used everywhere navigation needs to know which level is active (whether Back does anything, whether the reveal/stagger timing on first open applies — see §3).

_input()’s confirm branch changes from _choose(String(_cards[0].get_meta("mode"))) to reading the whole item back off the card (_cards[0].get_meta("item"), the Dictionary itself) and calling (_cards[0].get_meta("item") as Dictionary)["on_confirm"].call().

A new Back/Cancel handler (ui_cancel/JOY_BUTTON_B/Esc) is added to _input(), active only when not _parent_items.is_empty(). _carousel_index means different things in the two lists (an index into the mode list vs. an index into the top-level list), so popping back must not just swap _items and leave the old index in place — it has to explicitly re-resolve which slot “Play” occupies in the restored list: swap _items back to _parent_items, clear _parent_items, set _carousel_index to _parent_items.find_custom(func(i): return i["id"] == "play") (this screen only ever has one drill-in point, so there’s no need for a general breadcrumb stack — just the single parent-list slot and a lookup by id, not a remembered numeric index that could point at the wrong item once the list underneath it has changed), then re-render. At the top level (_parent_items.is_empty()), Back/Cancel is a no-op (nowhere to go — this is the root screen, same as today).

Level Confirm Left/Right Back/Cancel
Top level (Play/Ship/Shop/Enemies) that item’s on_confirm steps the top-level carousel no-op (root screen)
Mode sub-carousel (drilled in via Play, only reachable once >1 mode exists) _choose(mode_id) steps the mode carousel pops back to top level, centered on Play
Ship carousel (ShipConfigPanel, unchanged) persist meta.selected_ship, stay open pages to next/prev ship close() (unchanged)

Kept, unmodified: every carousel-mechanics test from the 2026-07-05 plan (slot math/scale/alpha, drag commit/cancel, reveal-fade target-alpha, MenuNav stepping) — the view layer’s mechanics don’t change, only what feeds it. ShipConfigPanel’s own suite is untouched (not in scope here).

Removed/rewritten: test_shop_entry_emits_shop_requested and its siblings (currently finding the Shop/Ship/Enemies footer button by walking box’s children and pressing it) — these become carousel-navigation tests instead: step/confirm to the Shop/Ship/Enemies card and confirm it, asserting the right signal fires.

New:

  • Top-level list shape: exactly [Play, Ship] when has_progressed == false; [Play, Ship, Shop, Enemies] once has_progressed == true.
  • Confirming Play with exactly one mode available launches it directly (mode_chosen fires with the right id, no sub-carousel ever renders, _parent_items stays empty).
  • Confirming Play with more than one mode available drills into the mode sub-carousel (_items becomes the mode list, _parent_items becomes the top-level list, centered on the first mode) — exercised by temporarily flipping V01_CRYSTALS_ONLY in the test the same way any existing test in this file already does for that flag, if that’s the established pattern (check tests/test_start_menu_modes.gd), otherwise construct the mode list directly and call _activate_play().
  • From the mode sub-carousel, confirming a mode calls _choose(mode_id) — unchanged behavior, new path to it.
  • From the mode sub-carousel, Back/Cancel returns to the top level, re-centered on Play, and a second Back/Cancel at the top level is a no-op.
  • Confirming Ship/Shop/Enemies cards fires the right signal (ship_config_requested/ shop_requested/bestiary_requested) and does NOT touch _parent_items.
  • Remote Control is NOT present anywhere in _items/_cards at either level, and its footer button still works exactly as today (unchanged code path).

ui/start_menu.gd-only change, no /sim files touched — the pinned determinism baseline holds by construction; verify per the standard bh-dev-chunk ritual anyway. This changes a well-worn, high-traffic screen (the very first thing every player sees), so include a real-device check (Mac export at minimum, bh-deploy to the Apple TV if this session’s cadence continues) — specifically confirm the Play → mode-sub-carousel → Back round trip feels right on a real remote/controller, not just headlessly.