Skip to content

Start Menu Carousel 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: Restyle the start menu’s mode picker as a hologram-bracket carousel (matching ui/meta_shop_panel.gd’s shipped pattern), remove the inline ship-picker row, and turn ui/ship_config_panel.gd’s single-ship display into a pre-run pageable carousel for choosing a ship.

Architecture: Port (don’t extract/share) the shop’s carousel pattern into ui/start_menu.gd for the mode list (full 3-up hologram-bracket carousel: center + dimmed side neighbors, drag + MenuNav step + confirm). ui/ship_config_panel.gd gets a simpler single-item paging carousel (no visible side neighbors — slide+crossfade the whole hero+weapon-ring composition), gated to sim == null (pre-run) only.

Tech Stack: Godot 4.6.3 / GDScript, GUT for tests.

  • /sim/* is untouched by this plan — render/UI only, so the pinned determinism baseline (snapshot_string().hash()=4217109746, state_checksum()=2666143677) must hold by construction. Re-verify anyway per Task 9.
  • Follow bh-dev-chunk ritual for every task: godot --headless --path . --import after any new inner class, boot-check (--quit-after 90 | grep "SCRIPT ERROR" must be empty), full suite (-gdir=res://tests -ginclude_subdirs -gexit) + bash scripts/check-test-count.sh.
  • GUT assertion methods are assert_lte/assert_gte, not assert_le/assert_ge.
  • Commit after each task (one chunk = one commit), on main, no --no-verify.
  • No change to ShopCategories, MetaState fields, ShipBonuses, or meta_shop_panel.gd itself.

Section titled “Task 1: Mode carousel — Hologram Brackets card + slot math (ui/start_menu.gd)”

Files:

  • Modify: ui/start_menu.gd
  • Test: tests/test_start_menu.gd

Interfaces:

  • Produces: StartMenu._HologramBrackets (inner class, same shape as MetaShopPanel._HologramBrackets), StartMenu._carousel_root: Control, StartMenu._items: Array (each entry a [id, title, desc, accent] array — the existing modes array shape), StartMenu._carousel_index: int, StartMenu._center() -> Vector2, StartMenu._slot_side_offset() -> float, StartMenu._compute_slots(center_i: int, side_offset: float) -> Array, StartMenu._place_card(card: Button, idx: int, x_off: float, scl: float) -> Vector2, StartMenu._make_mode_card(item: Array) -> Button (replaces _make_card).

  • Consumes: nothing new from other tasks.

  • Step 1: Write the failing test

# Add to tests/test_start_menu.gd
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
assert_eq(m._cards.size(), 1, "V01_CRYSTALS_ONLY=true -> a single centered mode card")
assert_almost_eq(m._cards[0].scale.x, StartMenu.CENTER_SCALE, 0.01)
assert_almost_eq(m._cards[0].modulate.a, 1.0, 0.01)
func test_mode_carousel_shows_dimmed_side_neighbours_for_three_items() -> void:
var m := StartMenu.new()
m._items = [["a", "A", "desc a", Color.RED], ["b", "B", "desc b", Color.GREEN], ["c", "C", "desc c", Color.BLUE]]
add_child_autofree(m)
await get_tree().process_frame
m._carousel_index = 0
m._render_carousel(0)
assert_eq(m._cards.size(), 3, "center + 2 side neighbours")
assert_almost_eq(m._cards[0].scale.x, StartMenu.CENTER_SCALE, 0.01, "center card")
assert_almost_eq(m._cards[1].scale.x, StartMenu.SIDE_SCALE, 0.01, "side card")
assert_almost_eq(m._cards[1].modulate.a, StartMenu.SIDE_ALPHA, 0.01, "side card dimmed")
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit Expected: FAIL — CENTER_SCALE/_render_carousel/etc. don’t exist yet.

  • Step 3: Add carousel constants + _HologramBrackets + slot/placement math + card builder

In ui/start_menu.gd, add alongside the existing consts (near CARD_W/CARD_H):

# Carousel layout — ports ui/meta_shop_panel.gd's validated constants verbatim (see
# docs/superpowers/specs/2026-07-02-shop-carousel-hologram-design.md). Computed fresh from
# the real viewport every render, not a fixed design-space value (the shop carousel's fix
# for a real-device off-center bug).
const CENTER_Y_FRAC := 0.5
const CENTER_SCALE := 1.4
const SIDE_SCALE := 0.55
const SIDE_ALPHA := 0.4
const SIDE_GAP := 26.0
const ANIM_DURATION := 0.3
const TRAVEL_FRACTION := 0.6
const DRAG_COMMIT_FRACTION := 0.22

Add member vars alongside the existing ones:

var _carousel_root: Control
var _items: Array = [] # [id, title, desc, accent] per mode
var _carousel_index: int = 0

Add these methods (near _make_card):

func _viewport_size() -> Vector2:
return get_viewport().get_visible_rect().size
func _center() -> Vector2:
var vp := _viewport_size()
return Vector2(vp.x * 0.5, vp.y * CENTER_Y_FRAC)
func _slot_side_offset() -> float:
return CARD_W * 0.5 * CENTER_SCALE + SIDE_GAP + CARD_W * SIDE_SCALE * 0.5
# [index, x_offset, scale, alpha, is_center] for whichever items are visible around center_i.
func _compute_slots(center_i: int, side_offset: float) -> Array:
var n := _items.size()
var slots: Array = [[center_i, 0.0, CENTER_SCALE, 1.0, true]]
if n >= 3:
slots.append([(center_i - 1 + n) % n, -side_offset, SIDE_SCALE, SIDE_ALPHA, false])
slots.append([(center_i + 1) % n, side_offset, SIDE_SCALE, SIDE_ALPHA, false])
elif n == 2:
slots.append([(center_i + 1) % n, side_offset, SIDE_SCALE, SIDE_ALPHA, false])
return slots
func _place_card(card: Button, idx: int, x_off: float, scl: float) -> Vector2:
card.set_meta("item_idx", idx)
var card_size: Vector2 = card.custom_minimum_size
card.pivot_offset = card_size * 0.5
card.scale = Vector2(scl, scl)
var center := _center()
return Vector2(center.x + x_off - card_size.x * 0.5, center.y - card_size.y * 0.5)

Add the hologram-bracket inner class (bottom of file, alongside _AccentDot):

# Hologram Corner Brackets — direct port of MetaShopPanel._HologramBrackets (see that file's
# own comment for the validation history). Ported independently, not shared, per this
# feature's design doc (docs/superpowers/specs/2026-07-05-start-menu-carousel-design.md).
class _HologramBrackets extends Control:
func _draw() -> void:
var accent: Color = get_meta("accent", Color.CYAN)
var s := size
var arm := minf(s.x, s.y) * 0.12
var col := Color(accent.r, accent.g, accent.b, 0.9)
draw_line(Vector2(0, arm), Vector2.ZERO, col, 2.0)
draw_line(Vector2.ZERO, Vector2(arm, 0), col, 2.0)
draw_line(Vector2(s.x - arm, 0), Vector2(s.x, 0), col, 2.0)
draw_line(Vector2(s.x, 0), Vector2(s.x, arm), col, 2.0)
draw_line(Vector2(0, s.y - arm), Vector2(0, s.y), col, 2.0)
draw_line(Vector2(0, s.y), Vector2(arm, s.y), col, 2.0)
draw_line(Vector2(s.x - arm, s.y), Vector2(s.x, s.y), col, 2.0)
draw_line(Vector2(s.x, s.y), Vector2(s.x, s.y - arm), col, 2.0)

Replace _make_card with a hologram-styled version that keeps the existing accent-dot + title + description content:

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)
card.focus_mode = Control.FOCUS_ALL
card.custom_minimum_size = Vector2(CARD_W, CARD_H)
card.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
card.add_theme_stylebox_override("hover", StyleBoxEmpty.new())
card.add_theme_stylebox_override("pressed", StyleBoxEmpty.new())
card.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
var brackets := _HologramBrackets.new()
brackets.set_anchors_preset(Control.PRESET_FULL_RECT)
brackets.mouse_filter = Control.MOUSE_FILTER_IGNORE
brackets.set_meta("accent", accent)
card.add_child(brackets)
var dot := _AccentDot.new()
dot.set_meta("c", accent)
dot.position = Vector2(26, CARD_H * 0.5)
card.add_child(dot)
var name_lbl := Label.new()
name_lbl.text = title
name_lbl.position = Vector2(58, 13)
name_lbl.add_theme_font_override("font", NeonTheme.title_font())
name_lbl.add_theme_font_size_override("font_size", 30)
name_lbl.add_theme_color_override("font_color", accent)
name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
card.add_child(name_lbl)
var desc_lbl := Label.new()
desc_lbl.text = desc
desc_lbl.position = Vector2(58, 50)
desc_lbl.add_theme_font_override("font", NeonTheme.mono_font())
desc_lbl.add_theme_font_size_override("font_size", 15)
desc_lbl.add_theme_color_override("font_color", Color(0.74, 0.8, 0.9))
desc_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
card.add_child(desc_lbl)
return card

Add _render_carousel (this task builds it without the drag/animation params — animation direction wiring lands in Task 4; call it with anim_direction always 0 for now):

func _render_carousel(anim_direction: int = 0) -> void:
for c in _carousel_root.get_children():
c.queue_free()
_cards.clear()
if _items.is_empty():
return
var side_offset := _slot_side_offset()
var slots := _compute_slots(_carousel_index, side_offset)
for slot in slots:
var idx: int = slot[0]
var x_off: float = slot[1]
var scl: float = slot[2]
var a: float = slot[3]
var is_center: bool = slot[4]
var card := _make_mode_card(_items[idx])
_carousel_root.add_child(card)
var final_pos := _place_card(card, idx, x_off, scl)
card.position = final_pos
card.modulate = Color(1, 1, 1, a)
_cards.append(card)
if is_center:
card.grab_focus.call_deferred()
card.pressed.connect(func() -> void: _ui_select(); _choose(String(card.get_meta("mode"))))
else:
card.pressed.connect(func() -> void: _jump_to(idx))

Add a stub _jump_to (fleshed out with animation in Task 4, but must exist now so the connection above compiles):

func _jump_to(idx: int) -> void:
_carousel_index = idx
_ui_nav()
_render_carousel(0)

Now wire this into _ready(): replace the existing mode-card loop —

var d := REVEAL_STAGGER * 2.0
for m in modes:
var c := _make_card(m[0], m[1], m[2], m[3])
box.add_child(c)
_reveal.append({"node": c, "delay": d})
d += REVEAL_STAGGER

— with:

_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)
var d := REVEAL_STAGGER * 2.0
for card in _cards:
_reveal.append({"node": card, "delay": d})
d += REVEAL_STAGGER

Remove the now-unused _make_card function entirely (superseded by _make_mode_card).

  • Step 4: Run test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit Expected: PASS (both new tests).

  • Step 5: Full boot-check
Terminal window
godot --headless --path . --import
godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"

Expected: import succeeds; grep produces no output.

  • Step 6: Commit
Terminal window
git add ui/start_menu.gd tests/test_start_menu.gd
git commit -m "feat(start-menu): hologram-bracket carousel card + slot math for the mode picker"

Section titled “Task 2: Mode carousel — navigation (step + confirm), ui/start_menu.gd”

Files:

  • Modify: ui/start_menu.gd
  • Test: tests/test_start_menu.gd

Interfaces:

  • Consumes: _carousel_index, _items, _render_carousel(anim_direction), _jump_to(idx) from Task 1.

  • Produces: StartMenu._carousel_step(direction: int) -> void.

  • Step 1: Write the failing test

func test_carousel_step_advances_index_and_wraps() -> void:
var m := StartMenu.new()
m._items = [["a","A","d",Color.RED], ["b","B","d",Color.GREEN], ["c","C","d",Color.BLUE]]
add_child_autofree(m)
await get_tree().process_frame
m._carousel_index = 0
m._render_carousel(0)
m._carousel_step(-1)
assert_eq(m._carousel_index, 2, "stepping left from 0 wraps to the last item")
func test_confirm_activates_the_centered_mode() -> void:
var m := StartMenu.new()
add_child_autofree(m)
await get_tree().process_frame
watch_signals(m)
var confirm := InputEventAction.new()
confirm.action = "ui_accept"
confirm.pressed = true
m._input(confirm)
assert_signal_emitted_with_parameters(m, "mode_chosen", ["crystals"])
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit Expected: FAIL — _carousel_step doesn’t exist; _input still uses the old _sel grid logic so confirm activates the wrong thing (or errors on an empty _cards-index path).

  • Step 3: Add _carousel_step and rewrite _input’s confirm/nav branches
func _carousel_step(direction: int) -> void:
if _items.is_empty():
return
_carousel_index = (_carousel_index + direction + _items.size()) % _items.size()
_ui_nav()
_render_carousel(direction)

Replace the body of _input (the existing _sel-wrap grid logic) with:

func _input(event: InputEvent) -> void:
if _cards.is_empty() or not visible:
return
var confirm := event.is_action_pressed("ui_accept")
if not confirm and event is InputEventJoypadButton:
confirm = event.pressed and event.button_index == JOY_BUTTON_A
if confirm:
_ui_select()
# _cards[0] is always the centered card -- _compute_slots() puts the center slot first.
var sel_mode := String(_cards[0].get_meta("mode", "survival"))
if sel_mode == "remote":
_on_remote_requested()
elif sel_mode == "shop":
shop_requested.emit()
elif sel_mode == "bestiary":
bestiary_requested.emit()
elif sel_mode == "ship_config":
ship_config_requested.emit()
else:
_choose(sel_mode)
get_viewport().set_input_as_handled()
return
var fwd := MenuNav.is_right(event)
var back_dir := MenuNav.is_left(event)
if not (fwd or back_dir):
return
var now := Time.get_ticks_msec()
if now - _last_nav_ms < NAV_DEBOUNCE_MS:
get_viewport().set_input_as_handled()
return
_last_nav_ms = now
_carousel_step(1 if fwd else -1)
get_viewport().set_input_as_handled()

Note: the footer buttons (Ship/Shop/Enemies/Remote) stay as regular Buttons connected to their own .pressed signals (unchanged) — they are NOT part of _items/the carousel, so _cards[0] (confirm target) only ever refers to the centered mode card. _sel and the old up/down (MenuNav.is_down/is_up) handling are removed entirely — a carousel has no second row, matching the shop carousel’s own “up/down dropped, not repurposed” precedent.

Remove the now-dead _sel member var and refocus()’s _cards[_sel] reference — replace refocus() with:

func refocus() -> void:
if not _cards.is_empty():
_cards[0].grab_focus.call_deferred()
  • Step 4: Run test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit Expected: PASS.

  • Step 5: Full suite + boot-check
Terminal window
godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
bash scripts/check-test-count.sh

Expected: empty grep; full suite green; count guard passes.

  • Step 6: Commit
Terminal window
git add ui/start_menu.gd tests/test_start_menu.gd
git commit -m "feat(start-menu): carousel step navigation + confirm activates the centered mode"

Section titled “Task 3: Mode carousel — footer buttons + reveal wiring sanity, ui/start_menu.gd”

Files:

  • Modify: ui/start_menu.gd
  • Test: tests/test_start_menu.gd

Interfaces:

  • Consumes: everything from Tasks 1–2.
  • Produces: nothing new — this task fixes footer-button focus interaction with the carousel (they must not be reachable via left/right, only via direct tap/click or a future Y/tab-style switch — for this plan, direct tap/click only, matching current behavior since footer buttons never were part of _sel’s left/right cycle… actually they WERE part of _cards/_sel before. This task makes that explicit and tested).

Context: before this change, footer buttons (_footer_btn) were appended to _cards and were reachable by cycling _sel with left/right, same as mode cards. After Tasks 1–2, only mode cards live in the carousel’s _cards/_carousel_indexated cycle — footer buttons are separate Button` nodes the player reaches only by direct tap/click, or standard Godot focus-neighbor Tab-style traversal (unchanged default engine behavior, not custom code). This task adds the test that locks that in, since Task 2 changed the behavior silently.

  • Step 1: Write the failing test
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")
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit Expected: FAIL if footer buttons still append to _cards (check _footer_btn’s body from the original file — it currently does _cards.append(b)).

  • Step 3: Stop appending footer buttons to _cards

In _footer_btn, remove the _cards.append(b) line — footer buttons are plain buttons with their own .pressed connections (already wired at each call site in _ready()), and don’t need to be in _cards now that confirm/nav only look at the mode carousel.

  • Step 4: Run test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit Expected: PASS.

  • Step 5: Full suite + boot-check (same commands as Task 2 Step 5)

  • Step 6: Commit

Terminal window
git add ui/start_menu.gd tests/test_start_menu.gd
git commit -m "fix(start-menu): footer buttons no longer ride the mode carousel's card list"

Section titled “Task 4: Mode carousel — touch/mouse drag + tween transitions, ui/start_menu.gd”

Files:

  • Modify: ui/start_menu.gd
  • Test: tests/test_start_menu.gd

Interfaces:

  • Consumes: _render_carousel, _compute_slots, _place_card, _carousel_step, _jump_to from Tasks 1–2.
  • Produces: StartMenu._begin_drag(x: float), StartMenu._update_drag(x: float), StartMenu._end_drag(x: float), StartMenu._complete_drag(direction: int), StartMenu._cancel_drag(), drag state vars _drag_active, _drag_start_x, _drag_side_offset, _drag_incoming_dir, _transitioning.

This ports MetaShopPanel’s validated drag implementation (ui/meta_shop_panel.gd), adapted to _items/_make_mode_card/_choose instead of shop defs/_activate. The full adapted code is given in Step 3 below.

  • Step 1: Write the failing test
func test_drag_past_commit_threshold_advances_the_carousel() -> void:
var m := StartMenu.new()
m._items = [["a","A","d",Color.RED], ["b","B","d",Color.GREEN], ["c","C","d",Color.BLUE], ["d","D","d",Color.YELLOW]]
add_child_autofree(m)
await get_tree().process_frame
m._carousel_index = 0
m._render_carousel(0)
var side_offset := m._slot_side_offset()
m._begin_drag(500.0)
m._update_drag(500.0 - side_offset * (StartMenu.DRAG_COMMIT_FRACTION + 0.05))
m._end_drag(500.0 - side_offset * (StartMenu.DRAG_COMMIT_FRACTION + 0.05))
assert_eq(m._carousel_index, 1, "a leftward drag past the commit threshold advances to the next item")
func test_short_drag_springs_back_without_changing_index() -> void:
var m := StartMenu.new()
m._items = [["a","A","d",Color.RED], ["b","B","d",Color.GREEN], ["c","C","d",Color.BLUE]]
add_child_autofree(m)
await get_tree().process_frame
m._carousel_index = 0
m._render_carousel(0)
var side_offset := m._slot_side_offset()
m._begin_drag(500.0)
m._update_drag(500.0 - side_offset * (StartMenu.DRAG_COMMIT_FRACTION - 0.05))
m._end_drag(500.0 - side_offset * (StartMenu.DRAG_COMMIT_FRACTION - 0.05))
assert_eq(m._carousel_index, 0, "a short drag springs back, index unchanged")
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit Expected: FAIL — _begin_drag/_update_drag/_end_drag don’t exist yet.

  • Step 3: Port the drag implementation

Add the member vars (near _carousel_index):

var _drag_active := false
var _drag_start_x := 0.0
var _drag_side_offset := 0.0
var _drag_incoming_dir := 0
var _transitioning := false

Replace the Task 1 stub _jump_to with the real wraparound-aware version:

# Only the immediate left/right neighbours are ever tappable, so the direction is exactly
# which way we stepped -- diff is +1/-1 except at the wraparound seam, which needs the
# short way round (e.g. index 0 -> n-1 is a "previous" step, not n-1 forward steps).
func _jump_to(idx: int) -> void:
var n := _items.size()
var diff: int = idx - _carousel_index
if diff > n / 2:
diff -= n
elif diff < -n / 2:
diff += n
_carousel_index = idx
_ui_nav()
_render_carousel(signi(diff))

Replace the Task 1 _render_carousel with the animation-aware version (adds enter/exit tweens when anim_direction != 0; identical to the anim_direction == 0 path otherwise):

func _render_carousel(anim_direction: int = 0) -> void:
var old_children := _carousel_root.get_children()
var n := _items.size()
if n == 0:
for c in old_children:
c.queue_free()
_cards.clear()
return
var side_offset := _slot_side_offset()
var travel: float = side_offset * TRAVEL_FRACTION
if anim_direction != 0 and not old_children.is_empty():
var exit_dx: float = -travel * anim_direction
for old_card in old_children:
var tw := create_tween()
tw.set_ease(Tween.EASE_IN)
tw.set_trans(Tween.TRANS_CUBIC)
tw.tween_property(old_card, "position:x", old_card.position.x + exit_dx, ANIM_DURATION)
tw.parallel().tween_property(old_card, "modulate:a", 0.0, ANIM_DURATION * 0.8)
tw.tween_callback(old_card.queue_free)
else:
for c in old_children:
c.queue_free()
_cards.clear()
var slots := _compute_slots(_carousel_index, side_offset)
for slot in slots:
var idx: int = slot[0]
var x_off: float = slot[1]
var scl: float = slot[2]
var a: float = slot[3]
var is_center: bool = slot[4]
var card := _make_mode_card(_items[idx])
_carousel_root.add_child(card)
var final_pos := _place_card(card, idx, x_off, scl)
_cards.append(card)
if is_center:
card.grab_focus.call_deferred()
card.pressed.connect(func() -> void: _ui_select(); _choose(String(card.get_meta("mode"))))
else:
card.pressed.connect(func() -> void: _jump_to(idx))
if anim_direction != 0:
var enter_dx: float = travel * anim_direction
card.position = Vector2(final_pos.x + enter_dx, final_pos.y)
card.modulate = Color(1, 1, 1, 0.0)
var tw := create_tween()
tw.set_ease(Tween.EASE_OUT)
tw.set_trans(Tween.TRANS_CUBIC)
tw.tween_property(card, "position:x", final_pos.x, ANIM_DURATION)
tw.parallel().tween_property(card, "modulate:a", a, ANIM_DURATION)
else:
card.position = final_pos
card.modulate = Color(1, 1, 1, a)

Add _begin_drag/_update_drag:

func _begin_drag(x: float) -> void:
if _items.size() < 2 or _drag_active or _transitioning:
return
_drag_active = true
_drag_start_x = x
_drag_incoming_dir = 0
_drag_side_offset = _slot_side_offset()
for c in _carousel_root.get_children():
c.set_meta("drag_base_x", c.position.x)
func _update_drag(x: float) -> void:
if not _drag_active:
return
var dx: float = x - _drag_start_x
# Dragging RIGHT (dx>0) reveals the PREVIOUS item (direction -1); dragging LEFT reveals NEXT (+1).
if _drag_incoming_dir == 0 and absf(dx) > 10.0:
_drag_incoming_dir = -1 if dx > 0 else 1
# Lazily build the 4th "incoming" card, one slot further out than the side card that's
# about to become the new center: `new_index + direction`, where `new_index` is what
# _complete_drag will also compute as the post-commit center. Gated to n>=4 (for n==3,
# center+both neighbours already account for every item; for n==2 the "other" item is
# already one of the 2 existing cards).
if _items.size() >= 4:
var new_index: int = (_carousel_index + _drag_incoming_dir + _items.size()) % _items.size()
var incoming_idx: int = (new_index + _drag_incoming_dir + _items.size()) % _items.size()
var incoming_x_off: float = _drag_side_offset * _drag_incoming_dir
var card := _make_mode_card(_items[incoming_idx])
_carousel_root.add_child(card)
var final_pos := _place_card(card, incoming_idx, incoming_x_off, SIDE_SCALE)
card.modulate = Color(1, 1, 1, 0.0)
card.set_meta("drag_base_x", final_pos.x - dx) # so base_x + dx == final_pos.x right now
card.set_meta("drag_incoming_alpha", SIDE_ALPHA)
card.position.y = final_pos.y
card.pressed.connect(func() -> void: _jump_to(incoming_idx))
# NOT appended to _cards here -- it's a transient mid-gesture visual; _complete_drag
# promotes it (by matching item_idx) or _cancel_drag frees it without ever adding it.
for c in _carousel_root.get_children():
if not c.has_meta("drag_base_x"):
continue
c.position.x = float(c.get_meta("drag_base_x")) + dx
if c.has_meta("drag_incoming_alpha"):
var t: float = clampf(absf(dx) / maxf(_drag_side_offset, 1.0), 0.0, 1.0)
c.modulate.a = t * float(c.get_meta("drag_incoming_alpha"))

Add _end_drag/_complete_drag/_cancel_drag:

func _end_drag(x: float) -> void:
if not _drag_active:
return
_drag_active = false
var dx: float = x - _drag_start_x
var commit_threshold: float = _drag_side_offset * DRAG_COMMIT_FRACTION
if _drag_incoming_dir != 0 and absf(dx) >= commit_threshold:
_complete_drag(_drag_incoming_dir)
else:
_cancel_drag()
# Tween every currently-visible card from its live mid-drag position to its TRUE resting slot
# for the new center index, matched by the item_idx each card was tagged with when placed
# (not by role) -- robust regardless of exactly how far the drag got before release.
func _complete_drag(direction: int) -> void:
_transitioning = true
var new_index: int = (_carousel_index + direction + _items.size()) % _items.size()
_carousel_index = new_index
_ui_nav()
var side_offset := _slot_side_offset()
var target_slots := _compute_slots(new_index, side_offset)
var matched: Dictionary = {} # item_idx -> [x_off, scale, alpha, is_center]
for slot in target_slots:
matched[int(slot[0])] = [slot[1], slot[2], slot[3], slot[4]]
_cards.clear()
for c in _carousel_root.get_children():
if not c.has_meta("item_idx"):
c.queue_free()
continue
var idx: int = c.get_meta("item_idx")
var tw := create_tween()
tw.set_ease(Tween.EASE_OUT)
tw.set_trans(Tween.TRANS_CUBIC)
if matched.has(idx):
var target: Array = matched[idx]
var is_center: bool = target[3]
var final_pos := _place_card(c, idx, target[0], target[1])
tw.tween_property(c, "position", final_pos, ANIM_DURATION)
tw.parallel().tween_property(c, "modulate:a", target[2], ANIM_DURATION)
_cards.append(c)
# A card kept across a completed drag carries whatever `pressed` connection it got
# from its LAST render -- its ROLE can change here (a side card can become the new
# center, or vice versa), so that stale wiring must be replaced.
for conn in c.pressed.get_connections():
c.pressed.disconnect(conn["callable"])
if is_center:
c.grab_focus.call_deferred()
c.pressed.connect(func() -> void: _choose(String(c.get_meta("mode"))))
else:
c.pressed.connect(func() -> void: _jump_to(idx))
c.remove_meta("drag_base_x")
c.remove_meta("drag_incoming_alpha")
else:
var exit_dx: float = side_offset * TRAVEL_FRACTION * signi(direction)
tw.tween_property(c, "position:x", c.position.x + exit_dx, ANIM_DURATION)
tw.parallel().tween_property(c, "modulate:a", 0.0, ANIM_DURATION * 0.8)
tw.tween_callback(c.queue_free)
# _cards[0] must be the actual center -- the confirm handler in _input reads _cards[0]
# unconditionally. Re-sort since this loop follows scene-tree child order, not slot role.
for i in _cards.size():
if int(_cards[i].get_meta("item_idx")) == new_index:
var tmp: Button = _cards[0]
_cards[0] = _cards[i]
_cards[i] = tmp
break
var reset_tw := create_tween()
reset_tw.tween_interval(ANIM_DURATION)
reset_tw.tween_callback(func() -> void: _transitioning = false)
func _cancel_drag() -> void:
_transitioning = true
for c in _carousel_root.get_children():
if not c.has_meta("drag_base_x"):
continue
var tw := create_tween()
tw.set_ease(Tween.EASE_OUT)
tw.set_trans(Tween.TRANS_CUBIC)
if c.has_meta("drag_incoming_alpha"):
tw.tween_property(c, "position:x", float(c.get_meta("drag_base_x")), ANIM_DURATION)
tw.parallel().tween_property(c, "modulate:a", 0.0, ANIM_DURATION)
tw.tween_callback(c.queue_free)
else:
tw.tween_property(c, "position:x", float(c.get_meta("drag_base_x")), ANIM_DURATION)
c.remove_meta("drag_base_x")
var reset_tw := create_tween()
reset_tw.tween_interval(ANIM_DURATION)
reset_tw.tween_callback(func() -> void: _transitioning = false)

Wire drag events into _input, inserted before the confirm/nav checks already there:

if event is InputEventScreenTouch:
if event.index != 0:
return
if event.pressed:
_begin_drag(event.position.x)
else:
_end_drag(event.position.x)
return
elif event is InputEventScreenDrag:
if event.index == 0:
_update_drag(event.position.x)
return
elif event is InputEventMouseMotion and _drag_active:
_update_drag(event.position.x)
return
  • Step 4: Run test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit Expected: PASS.

  • Step 5: Full suite + boot-check (same commands as Task 2 Step 5)

  • Step 6: Commit

Terminal window
git add ui/start_menu.gd tests/test_start_menu.gd
git commit -m "feat(start-menu): live-follow drag for the mode carousel, ported from the shop"

Task 5: Remove the inline ship-picker row (ui/start_menu.gd)

Section titled “Task 5: Remove the inline ship-picker row (ui/start_menu.gd)”

Files:

  • Modify: ui/start_menu.gd
  • Test: tests/test_start_menu.gd

Interfaces:

  • Consumes: nothing from prior tasks (independent removal).

  • Produces: nothing — pure deletion.

  • Step 1: Write the failing test

func test_no_ship_picker_tiles_on_the_start_menu() -> void:
var m := StartMenu.new()
add_child_autofree(m)
await get_tree().process_frame
for c in m._cards:
assert_false(String(c.get_meta("mode", "")).begins_with("ship:"),
"the inline ship-tile row is removed -- ship choice lives in ShipConfigPanel now")
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit Expected: FAIL — ship:-tagged cards still exist.

  • Step 3: Delete the ship row

Remove from ui/start_menu.gd: the ship_spacer/ship_label/ship_row construction block in _ready(), the for ship_id in ShipBonuses.ORDER: ... loop that builds _make_ship_button tiles, the _make_ship_button function itself, _pick_ship, the _ship_buttons member var, and the SHIP_TILE/SHIP_THUMB constants (no longer used anywhere in this file). Also remove the sel_mode.begins_with("ship:") branch from _input (dead now that no ship: card is ever built).

  • Step 4: Run test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit Expected: PASS.

  • Step 5: Full suite + boot-check (same commands as Task 2 Step 5)

  • Step 6: Commit

Terminal window
git add ui/start_menu.gd tests/test_start_menu.gd
git commit -m "refactor(start-menu): remove the inline ship-picker row (moved into ShipConfigPanel)"

Task 6: ShipConfigPanel — retain meta + pre-run flag, add ship-index browsing state

Section titled “Task 6: ShipConfigPanel — retain meta + pre-run flag, add ship-index browsing state”

Files:

  • Modify: ui/ship_config_panel.gd
  • Test: tests/test_ship_config_panel.gd

Interfaces:

  • Produces: ShipConfigPanel._meta: MetaState, ShipConfigPanel._pre_run: bool, ShipConfigPanel._ship_index: int, ShipConfigPanel._current_ship_id() -> String, ShipConfigPanel._rebuild_for_ship(ship_id: String, sim: Sim) -> void (extracted from the body of open_config).
  • Consumes: ShipBonuses.ORDER, ShipBonuses.DEFAULT_SHIP.

open_config(meta, sim) currently rebuilds the display inline and discards meta/sim afterward — nothing persists them for later input handling. This task retains what paging needs, without changing the panel’s visible behavior yet (paging lands in Task 8).

  • Step 1: Write the failing test
func test_pre_run_open_sets_pre_run_flag_and_ship_index() -> void:
var p := ShipConfigPanel.new()
add_child_autofree(p)
await get_tree().process_frame
var meta := MetaState.new()
meta.selected_ship = "amethyst"
p.open_config(meta, null)
assert_true(p._pre_run, "sim == null -> pre-run mode")
assert_eq(p._ship_index, ShipBonuses.ORDER.find("amethyst"), "ship index matches the equipped ship")
func test_mid_run_open_clears_pre_run_flag() -> void:
var p := ShipConfigPanel.new()
add_child_autofree(p)
await get_tree().process_frame
var sim := Sim.new(1, SimContentFixture.db())
p.open_config(MetaState.new(), sim)
assert_false(p._pre_run, "sim != null -> mid-run, no paging")
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ship_config_panel -gexit Expected: FAIL — _pre_run/_ship_index don’t exist yet.

  • Step 3: Add the retained state and refactor open_config

Add member vars (near _open):

var _meta: MetaState
var _pre_run: bool = false
var _ship_index: int = 0

Replace open_config’s body with:

func open_config(meta: MetaState, sim: Sim) -> void:
_open = true
visible = true
_meta = meta
_pre_run = sim == null
var ship_id := meta.selected_ship if meta != null else ShipBonuses.DEFAULT_SHIP
_ship_index = maxi(ShipBonuses.ORDER.find(ship_id), 0)
_rebuild_for_ship(ship_id, sim)
func _current_ship_id() -> String:
return ShipBonuses.ORDER[_ship_index]
func _rebuild_for_ship(ship_id: String, sim: Sim) -> void:
_title_lbl.text = ShipBonuses.name_for(ship_id).to_upper()
_bonus_lbl.text = ShipBonuses.label_for(ship_id) + (" · Lv %d" % sim.player.level if sim != null else "")
_ship_thumb.texture = load("res://render/ship_sprites/ship3d_%s.png" % ship_id)
for c in _weapon_cards:
c.queue_free()
_weapon_cards.clear()
var owned := sim.upgrade_system.active_weapon_views(sim) if sim != null else [{"glyph": "blade", "name": "Blade", "stat": ""}]
var slot_count := sim.max_weapon_slots if sim != null else ShipBonuses.weapon_slots_for(ship_id)
for i in range(slot_count):
var card := _make_weapon_card(owned[i] if i < owned.size() else {})
_weapon_cards.append(card)
_root.add_child(card)
_rebuild_drone_row(sim)
_layout()

(This is the same body open_config had before, just split so _rebuild_for_ship can be called again later, from paging, without re-doing the _open/visible/_pre_run setup.)

  • Step 4: Run test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ship_config_panel -gexit Expected: PASS — and all pre-existing tests in this file (listed in the design spec’s Testing section) still pass unmodified, since _rebuild_for_ship’s body is byte-identical to the old open_config body it was extracted from.

  • Step 5: Full suite + boot-check (same commands as Task 2 Step 5)

  • Step 6: Commit

Terminal window
git add ui/ship_config_panel.gd tests/test_ship_config_panel.gd
git commit -m "refactor(ship-config): retain meta + pre-run flag, extract _rebuild_for_ship"

Task 7: ShipConfigPanel — paging (browse) with slide+crossfade, confirm persists

Section titled “Task 7: ShipConfigPanel — paging (browse) with slide+crossfade, confirm persists”

Files:

  • Modify: ui/ship_config_panel.gd
  • Test: tests/test_ship_config_panel.gd

Interfaces:

  • Consumes: _pre_run, _ship_index, _current_ship_id(), _rebuild_for_ship() from Task 6.
  • Produces: ShipConfigPanel._page_ship(direction: int) -> void, ShipConfigPanel._confirm_ship() -> void.

Per the design spec, this carousel shows exactly one ship at a time (no visible dimmed side neighbors — there’s no room once the weapon ring is laid out) — paging is a slide+crossfade of the whole _root composition, not a 3-up hologram layout. Persistence happens on confirm, not on every page step (browsing doesn’t equip a ship until you commit).

  • Step 1: Write the failing test
func test_page_ship_wraps_and_rebuilds_without_persisting() -> void:
var p := ShipConfigPanel.new()
add_child_autofree(p)
await get_tree().process_frame
var meta := MetaState.new()
meta.selected_ship = ShipBonuses.ORDER[0]
p.open_config(meta, null)
p._page_ship(-1)
assert_eq(p._ship_index, ShipBonuses.ORDER.size() - 1, "paging left from index 0 wraps to the last ship")
assert_eq(meta.selected_ship, ShipBonuses.ORDER[0], "paging alone does not persist the selection")
assert_eq(p._title_lbl.text, ShipBonuses.name_for(ShipBonuses.ORDER[ShipBonuses.ORDER.size() - 1]).to_upper(),
"the display rebuilds for the newly-centered ship")
func test_confirm_ship_persists_when_owned() -> void:
var p := ShipConfigPanel.new()
add_child_autofree(p)
await get_tree().process_frame
var meta := MetaState.new()
meta.selected_ship = ShipBonuses.ORDER[0]
p.open_config(meta, null)
p._page_ship(1)
var expected := ShipBonuses.ORDER[1]
if meta.owns_ship(expected):
p._confirm_ship()
assert_eq(meta.selected_ship, expected, "confirm persists the centered, owned ship")
func test_confirm_ship_does_not_persist_when_locked() -> void:
var p := ShipConfigPanel.new()
add_child_autofree(p)
await get_tree().process_frame
var meta := MetaState.new()
var locked_idx := -1
for i in range(ShipBonuses.ORDER.size()):
if not meta.owns_ship(ShipBonuses.ORDER[i]):
locked_idx = i
break
if locked_idx == -1:
pass_test("no locked ship in this MetaState fixture -- nothing to assert")
return
meta.selected_ship = ShipBonuses.ORDER[0]
p.open_config(meta, null)
p._ship_index = locked_idx
p._confirm_ship()
assert_eq(meta.selected_ship, ShipBonuses.ORDER[0], "confirming a locked ship is a no-op")
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ship_config_panel -gexit Expected: FAIL — _page_ship/_confirm_ship don’t exist.

  • Step 3: Implement paging + confirm
const PAGE_ANIM_DURATION := 0.25
const PAGE_TRAVEL := 90.0
func _page_ship(direction: int) -> void:
var n := ShipBonuses.ORDER.size()
_ship_index = (_ship_index + direction + n) % n
_ui_nav()
var ship_id := _current_ship_id()
# Slide+crossfade the whole hero+ring composition: fade the current content out while
# sliding it further in the drag direction, rebuild for the new ship, then fade+slide in
# from the opposite side. Matches ANIM_DURATION/TRANS_CUBIC used by the shop/mode carousels
# for visual consistency, at a smaller travel distance (PAGE_TRAVEL) since this is a single
# hero composition, not a multi-slot carousel.
var out_tw := create_tween()
out_tw.set_ease(Tween.EASE_IN)
out_tw.set_trans(Tween.TRANS_CUBIC)
out_tw.tween_property(_root, "position:x", _root.position.x - PAGE_TRAVEL * direction, PAGE_ANIM_DURATION)
out_tw.parallel().tween_property(_root, "modulate:a", 0.0, PAGE_ANIM_DURATION)
out_tw.tween_callback(func() -> void:
_rebuild_for_ship(ship_id, null)
_root.position.x += PAGE_TRAVEL * direction * 2.0
var in_tw := create_tween()
in_tw.set_ease(Tween.EASE_OUT)
in_tw.set_trans(Tween.TRANS_CUBIC)
in_tw.tween_property(_root, "position:x", _root.position.x - PAGE_TRAVEL * direction, PAGE_ANIM_DURATION)
in_tw.parallel().tween_property(_root, "modulate:a", 1.0, PAGE_ANIM_DURATION))
func _confirm_ship() -> void:
var ship_id := _current_ship_id()
if _meta == null or not _meta.owns_ship(ship_id):
return
_meta.selected_ship = ship_id
MetaStore.save_state(_meta)

Locked-ship dimming (ported from the deleted _make_ship_button’s locked treatment): in _rebuild_for_ship, after setting _ship_thumb.texture, add:

_ship_thumb.modulate = Color(0.45, 0.45, 0.5) if (_meta != null and not _meta.owns_ship(ship_id)) else Color.WHITE
  • Step 4: Run test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ship_config_panel -gexit Expected: PASS. Note the tween-driven rebuild inside _page_ship runs on a callback, not synchronously — the test above calls _page_ship and asserts immediately, which only works because _rebuild_for_ship’s call sits behind the OUT tween’s tween_callback. If this proves flaky in practice (tween timing in a headless test), simplify Task 7 by rebuilding synchronously and only tweening the fade/slide as cosmetic polish — check the test’s real behavior before assuming the async version is correct; adjust to synchronous-rebuild if the tween-gated version doesn’t resolve within a single test frame.

  • Step 5: Full suite + boot-check (same commands as Task 2 Step 5)

  • Step 6: Commit

Terminal window
git add ui/ship_config_panel.gd tests/test_ship_config_panel.gd
git commit -m "feat(ship-config): pre-run ship paging with slide+crossfade, confirm persists"

Task 8: ShipConfigPanel — input wiring (left/right pages, confirm persists, gated pre-run)

Section titled “Task 8: ShipConfigPanel — input wiring (left/right pages, confirm persists, gated pre-run)”

Files:

  • Modify: ui/ship_config_panel.gd
  • Test: tests/test_ship_config_panel.gd

Interfaces:

  • Consumes: _page_ship, _confirm_ship, _pre_run from Tasks 6–7.

  • Produces: updated ShipConfigPanel._input.

  • Step 1: Write the failing test

func test_left_right_pages_ship_only_when_pre_run() -> void:
var p := ShipConfigPanel.new()
add_child_autofree(p)
await get_tree().process_frame
var meta := MetaState.new()
meta.selected_ship = ShipBonuses.ORDER[0]
p.open_config(meta, null)
# MenuNav.is_right() checks event.is_action_pressed("ui_right") (also "move_right"/
# "aim_right" -- see ui/menu_nav.gd), so a synthetic InputEventAction is enough, same
# pattern as the "ui_accept" confirm test just below.
var right := InputEventAction.new()
right.action = "ui_right"
right.pressed = true
p._input(right)
assert_eq(p._ship_index, 1, "right pages to the next ship")
func test_left_right_is_a_noop_mid_run() -> void:
var p := ShipConfigPanel.new()
add_child_autofree(p)
await get_tree().process_frame
var sim := Sim.new(1, SimContentFixture.db())
p.open_config(MetaState.new(), sim)
var before := p._ship_index
var right := InputEventAction.new()
right.action = "ui_right"
right.pressed = true
p._input(right)
assert_eq(p._ship_index, before, "mid-run: left/right does nothing")
func test_confirm_persists_the_centered_ship_when_pre_run() -> void:
var p := ShipConfigPanel.new()
add_child_autofree(p)
await get_tree().process_frame
var meta := MetaState.new()
meta.selected_ship = ShipBonuses.ORDER[0]
p.open_config(meta, null)
if meta.owns_ship(ShipBonuses.ORDER[0]):
var confirm := InputEventAction.new()
confirm.action = "ui_accept"
confirm.pressed = true
p._input(confirm)
assert_eq(meta.selected_ship, ShipBonuses.ORDER[0])
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ship_config_panel -gexit Expected: FAIL — _input doesn’t yet check _pre_run/call _page_ship/_confirm_ship.

  • Step 3: Extend _input

Insert into the existing _input (before the back check, since back/Esc must still work regardless of _pre_run):

if _pre_run:
var confirm := event.is_action_pressed("ui_accept")
if not confirm and event is InputEventJoypadButton:
var je: InputEventJoypadButton = event
confirm = je.pressed and je.button_index == JOY_BUTTON_A
if confirm:
_ui_select()
_confirm_ship()
get_viewport().set_input_as_handled()
return
if MenuNav.is_right(event) or MenuNav.is_left(event):
_page_ship(1 if MenuNav.is_right(event) else -1)
get_viewport().set_input_as_handled()
return

(Placed as the first block inside _input, before the existing back check — ui_cancel/ JOY_BUTTON_B/Esc still falls through to the existing close() call unchanged in both pre-run and mid-run cases.)

  • Step 4: Run test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ship_config_panel -gexit Expected: PASS.

  • Step 5: Full suite + boot-check (same commands as Task 2 Step 5)

  • Step 6: Commit

Terminal window
git add ui/ship_config_panel.gd tests/test_ship_config_panel.gd
git commit -m "feat(ship-config): wire left/right paging + confirm-to-persist, gated pre-run"

Task 9: Determinism, full-suite verification, and real-device check

Section titled “Task 9: Determinism, full-suite verification, and real-device check”

Files: none (verification only).

  • Step 1: Full suite + count guard
Terminal window
godot --headless --path . --import
godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
bash scripts/check-test-count.sh

Expected: empty grep; “All tests passed” or only the 2 pre-existing unrelated risky tests; count guard passes (matches tests/test_*.gd file count).

  • Step 2: Determinism baseline
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_checksum -gexit

Expected: PASS — this plan touches no /sim file, so the pinned baseline (snapshot_string().hash()=4217109746, state_checksum()=2666143677) must hold unchanged.

  • Step 3: Local macOS export + manual look
Terminal window
godot --headless --path . --export-release "macOS" builds/macos/BulletHeaven.app
open builds/macos/BulletHeaven.app

Manually confirm: the mode carousel renders centered and correctly styled with only “SURVIVAL” showing (V01_CRYSTALS_ONLY); the SHIP row is gone from the start menu; pressing the “Ship” footer button opens a carousel-capable ShipConfigPanel that pages between all 7 ShipBonuses.ORDER hulls with left/right, shows LOCKED ships dimmed, and confirming an owned ship sticks (re-open the panel and see it’s still centered on that ship next time).

  • Step 4: Real-device check (Apple TV, matching this session’s established cadence)

Follow the bh-deploy skill’s tvOS section: bump Sim_Const.BUILD in sim/constants.gd, verify the folded platform/tvos/ project (--import, boot-check, full suite matching root’s script count), export-pack + xcodebuild + devicectl install/launch. Confirm on-device: the carousel is legible/navigable via the Apple TV remote (D-pad emulation through MenuNav), and the Siri Remote filter (already shipped, unrelated to this change) still keeps the actual remote from moving anything.

  • Step 5: Final commit
Terminal window
git add sim/constants.gd
git commit -m "chore: bump BUILD for the start menu carousel deploy"