Skip to content

Shop Overhaul — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: use superpowers:subagent-driven-development or superpowers:executing-plans to implement this task-by-task. Each task ends green + committed via the bh-dev-chunk ritual (TDD → --import → boot-check → full GUT suite → scripts/check-test-count.sh → determinism). Steps use checkbox (- [ ]) syntax.

Goal: Decouple the shop from the death screen — make it a standalone, categorised two-level shop reachable from the start menu, the in-run pause menu, and a new Results death screen.

Architecture: MetaShopPanel becomes a reusable two-level shop (open_shop(meta, defs, return_to)): a root view of category tiles → a per-category card view. ResultsPanel becomes the survival/crystal death screen (Redeploy / Shop / Main Menu + run summary). StartMenu and PauseMenu each gain a Shop entry. main.gd routes all three entry points through one _open_shop(return_to) helper. Categories are a new data-driven category field on each meta_upgrade in bible.json.

Tech Stack: Godot 4.6 typed GDScript; CanvasLayer UI; GUT 9.6 headless tests.

  • No /sim changes. Pure UI + meta-progression (meta upgrades already apply outside the sim). The determinism baselines MUST NOT move — survival 1405185210/3122397125, crystals 91572468/1173256610. Re-run the determinism test each task and confirm it’s unchanged (no re-pin).
  • tvOS focus nav on every screen: debounced joypad/d-pad nav (NAV_DEBOUNCE_MS = 200), confirm on ui_accept OR JOY_BUTTON_A, centered/overscan-safe layout, reuse ui/shop_icons.gd glyphs.
  • Never clobber user://meta.json in tests — exercise MetaState.buy (pure) and the unaffordable (no-save) path only; never the buy-with-save path in a headless test (it writes the real save).
  • Categories (fixed order): Pilot / Drones / Arsenal / Utility. The root view lists only categories that have ≥1 upgrade (empty ones — e.g. Drones today has decoy entries; a truly empty category — auto-hide).
  • Each task: commit specific files only (the other agent commits to main; never git add -A; never touch CLAUDE.md).
  • data/bible.json — add category to all 13 meta_upgrades (Task 1).
  • ui/shop_categories.gd (new, pure)ShopCategories: the ordered category list + grouping helpers (Task 2). Pure (no DOM/Node) so it’s unit-testable.
  • ui/meta_shop_panel.gd — rewrite to the two-level standalone shop (Task 3).
  • ui/results_panel.gdshow_results becomes the 3-button death screen (Task 4).
  • ui/start_menu.gd — add a SHOP entry (Task 5).
  • ui/pause_menu.gd — add a Shop button (Task 6).
  • main.gd_open_shop(return_to) + rewire game-over/start/pause (Task 7).
  • Tests: tests/test_shop_categories.gd (new), rewrite tests/test_meta_shop.gd + tests/test_meta_shop_panel.gd, extend tests/test_results_panel.gd (new if absent), tests/test_start_menu.gd + tests/test_pause_menu.gd (new if absent).

Task 1: category field on every meta_upgrade

Section titled “Task 1: category field on every meta_upgrade”

Files:

  • Modify: data/bible.json (data.meta_upgrades[*])
  • Test: tests/test_shop_categories.gd (create; expanded in Task 2)

Interfaces — Produces: every meta_upgrade dict has a category ∈ {Pilot, Drones, Arsenal, Utility}.

Category assignment (by id):

Category ids
Pilot vitality, bulwark, haste, swiftness, thrusters, field-medic
Utility greed
Arsenal unlock-scatter
Drones decoy-power, decoy-duration, unlock-decoy-damage, unlock-decoy-tank, unlock-decoy-healer, unlock-decoy-ultimate
  • Step 1: Write the failing test (tests/test_shop_categories.gd)
extends GutTest
const ALLOWED := ["Pilot", "Drones", "Arsenal", "Utility"]
func test_every_meta_upgrade_has_a_valid_category() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
for m in content.meta_upgrades():
assert_true(m is Dictionary, "meta upgrade is a dict")
var cat := String((m as Dictionary).get("category", ""))
assert_true(cat in ALLOWED, "%s has a valid category (got '%s')" % [m.get("id", "?"), cat])
  • Step 2: Run it — expect FAIL (-gtest=res://tests/test_shop_categories.gd): every upgrade missing category.
  • Step 3: Add the field via a tab-indented python round-trip (clean one-key diff):
import json
p = "data/bible.json"
doc = json.load(open(p)); mu = doc["data"]["meta_upgrades"]
CAT = {
"vitality":"Pilot","bulwark":"Pilot","haste":"Pilot","swiftness":"Pilot",
"thrusters":"Pilot","field-medic":"Pilot","greed":"Utility","unlock-scatter":"Arsenal",
"decoy-power":"Drones","decoy-duration":"Drones","unlock-decoy-damage":"Drones",
"unlock-decoy-tank":"Drones","unlock-decoy-healer":"Drones","unlock-decoy-ultimate":"Drones",
}
for m in mu:
m["category"] = CAT[m["id"]] # KeyError = a new upgrade needs a category here
json.dump(doc, open(p,"w"), indent="\t", ensure_ascii=False); open(p,"a").write("\n")
  • Step 4: Run the test — expect PASS. Then full suite + count + determinism (unchanged). Commit data/bible.json tests/test_shop_categories.gd.

Task 2: ShopCategories pure grouping helper

Section titled “Task 2: ShopCategories pure grouping helper”

Files:

  • Create: ui/shop_categories.gd
  • Test: tests/test_shop_categories.gd (extend)

Interfaces — Produces:

  • ShopCategories.ORDER: Array[String] = ["Pilot", "Drones", "Arsenal", "Utility"]

  • ShopCategories.present(defs: Array) -> Array[String] — categories (in ORDER) with ≥1 upgrade.

  • ShopCategories.in_category(defs: Array, cat: String) -> Array — defs whose category == cat, preserving document order.

  • Step 1: Write failing tests (append to tests/test_shop_categories.gd)

func test_present_returns_categories_in_fixed_order_only_if_nonempty() -> void:
var defs := [
{"id":"a","category":"Arsenal"}, {"id":"b","category":"Pilot"}, {"id":"c","category":"Pilot"},
]
assert_eq(ShopCategories.present(defs), ["Pilot", "Arsenal"], "ordered + only non-empty")
func test_in_category_filters_and_preserves_order() -> void:
var defs := [{"id":"a","category":"Pilot"}, {"id":"b","category":"Drones"}, {"id":"c","category":"Pilot"}]
var pilots := ShopCategories.in_category(defs, "Pilot")
assert_eq(pilots.size(), 2)
assert_eq(String(pilots[0]["id"]), "a")
assert_eq(String(pilots[1]["id"]), "c")
func test_unknown_category_is_ignored_not_crash() -> void:
var defs := [{"id":"x","category":"Bogus"}]
assert_eq(ShopCategories.present(defs), [], "unknown categories don't appear")
  • Step 2: Run — expect FAIL (ShopCategories undefined).
  • Step 3: Implement ui/shop_categories.gd
class_name ShopCategories
# Pure grouping helpers for the categorised shop. No DOM/Node — unit-testable.
const ORDER: Array[String] = ["Pilot", "Drones", "Arsenal", "Utility"]
static func in_category(defs: Array, cat: String) -> Array:
var out: Array = []
for d in defs:
if d is Dictionary and String((d as Dictionary).get("category", "")) == cat:
out.append(d)
return out
static func present(defs: Array) -> Array[String]:
var out: Array[String] = []
for cat in ORDER:
if not in_category(defs, cat).is_empty():
out.append(cat)
return out
  • Step 4: --import (new class_name in a new file → refresh the class cache). Run — expect PASS. Full suite + count + determinism (unchanged). Commit ui/shop_categories.gd tests/test_shop_categories.gd.

Task 3: MetaShopPanel → two-level standalone shop

Section titled “Task 3: MetaShopPanel → two-level standalone shop”

Files:

  • Modify: ui/meta_shop_panel.gd
  • Test: rewrite tests/test_meta_shop.gd + tests/test_meta_shop_panel.gd

Interfaces — Consumes: ShopCategories (Task 2), MetaState (buy/cost/can_afford/is_maxed/ level_of/selected_decoy), MetaStore.save_state, ShopIcons.make, NeonTheme. Produces:

  • MetaShopPanel.open_shop(meta: MetaState, defs: Array, return_to: Callable) -> void — shows the ROOT category view (layer 80, on top of any menu). Stores return_to.
  • signal closed — emitted when the player backs out of the root.
  • Internal views: _show_root() (category tiles + banked-gold header), _show_category(cat) (that category’s cards + Back). Back: category→root→(close: hide_panel() + return_to.call() + closed).
  • Keeps: buy/persist (_activate/MetaState.buy/MetaStore.save_state), card UI (_make_card/ _set_card_text/_card_box/_label), grid layout (_columns_for/CARD_*), debounced 2D nav.
  • Removes: the run-summary header + the PLAY_ID “Play Again” card + restart_requested (the Results screen owns restart now) + the old show_shop(sim, meta, defs) entry point.

Behaviour detail:

  • layer = 80 (above pause 70 / start 60) so the shop covers whatever opened it.

  • Root header: title “SHOP” + “BANKED N gold”. Tiles = ShopCategories.present(defs); each tile is a _make_card(cat, accent, true, w) with _set_card_text(card, cat, "<n> upgrades", accent, "", ""). Activating a tile → _show_category(cat).

  • Category view: cards = ShopCategories.in_category(defs, cat) (reuse _make_upgrade_card) + a Back card at the end (__back__). Back → _show_root().

  • A buy stays in the category view and _rebuilds it (so cost/level/OWNED refresh) — same flash + save.

  • _input Back button (ui_cancel / JOY_BUTTON_B / Esc): in category → _show_root(); in root → close. (Add this alongside the existing confirm/nav handling.)

  • Step 1: Rewrite the tests (tests/test_meta_shop.gd, tests/test_meta_shop_panel.gd) to the new API. Drop the Play-Again/restart_requested/run-summary assertions. New assertions:

# tests/test_meta_shop.gd (rewrite)
extends GutTest
func _open(p: MetaShopPanel, meta: MetaState, defs: Array) -> void:
p.open_shop(meta, defs, func() -> void: pass)
func test_root_shows_one_tile_per_present_category() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new() # fresh, zero gold → no save path touched
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
_open(p, meta, content.meta_upgrades())
# one card per present category (no buy summary, no play card)
assert_eq(p._cards.size(), ShopCategories.present(content.meta_upgrades()).size(),
"root view shows one tile per non-empty category")
p.hide_panel()
func test_drilling_into_category_shows_its_cards_plus_back() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
_open(p, meta, content.meta_upgrades())
p._show_category("Pilot")
var n := ShopCategories.in_category(content.meta_upgrades(), "Pilot").size()
assert_eq(p._cards.size(), n + 1, "category view shows its cards + a Back card")
p.hide_panel()
func test_back_from_root_calls_return_to_and_emits_closed() -> void:
var meta := MetaState.new()
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
var returned := [false]
p.open_shop(meta, [], func() -> void: returned[0] = true)
watch_signals(p)
p._close() # the back-from-root path
assert_true(returned[0], "return_to callback fired")
assert_signal_emitted(p, "closed")
func test_activating_unaffordable_upgrade_is_noop() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new() # zero gold
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
_open(p, meta, content.meta_upgrades())
p._show_category("Pilot")
var first_stat := ShopCategories.in_category(content.meta_upgrades(), "Pilot")[0]
p._activate(String(first_stat["id"])) # unaffordable → buy() false → no save, no crash
assert_eq(meta.banked_gold, 0, "no gold spent on an unaffordable buy")
p.hide_panel()

(test_meta_shop_panel.gd: keep test_columns_scale_with_count; replace test_grid_built_for_defs with the root-tile-count assertion above — or delete it as redundant.)

  • Step 2: Run the tests — expect FAIL (old API gone / new methods missing).
  • Step 3: Rewrite ui/meta_shop_panel.gd. Keep the helpers listed above; replace the entry/header/_rebuild flow with open_shop + _show_root + _show_category + _close. Key new code:
signal closed
var _return_to: Callable = Callable()
var _view: String = "root" # "root" | "<category>"
func open_shop(meta: MetaState, defs: Array, return_to: Callable) -> void:
_meta = meta
_defs = defs
_return_to = return_to
visible = true
_show_root()
func _show_root() -> void:
_view = "root"
_build_header("SHOP")
_clear_grid()
var cats := ShopCategories.present(_defs)
_columns = _columns_for(cats.size())
var card_w := _card_width(_columns)
_grid.columns = _columns
for cat in cats:
var n := ShopCategories.in_category(_defs, cat).size()
var card := _make_card(cat, NeonTheme.CYAN, true, card_w)
_set_card_text(card, cat, "%d upgrades" % n, NeonTheme.CYAN, "", "")
_cards.append(card); _grid.add_child(card)
_finish_view()
func _show_category(cat: String) -> void:
_view = cat
_build_header(cat.to_upper())
_clear_grid()
var defs := ShopCategories.in_category(_defs, cat)
_columns = _columns_for(defs.size() + 1)
var card_w := _card_width(_columns)
_grid.columns = _columns
for def in defs:
_cards.append(_make_upgrade_card(def, card_w)); _grid.add_child(_cards[-1])
var back := _make_card("__back__", STEEL, true, card_w)
_set_card_text(back, "◀ BACK", "", STEEL, "", "")
_cards.append(back); _grid.add_child(back)
_finish_view()
func _close() -> void:
hide_panel()
if _return_to.is_valid():
_return_to.call()
closed.emit()
  • Factor _card_width(cols), _build_header(title) (title + “BANKED N gold”, no run summary), _clear_grid(), _finish_view() (the _sel/focus/entrance-tween block from the old _rebuild).

  • _activate(id): if id == "__back__"_show_root(); else the existing decoy-equip / buy logic, but on success _rebuild_current() (re-render the active category, not the old _rebuild). A tile id (a category name) → _show_category(id).

  • _input: add the Back action — ui_cancel / JOY_BUTTON_B / KEY_ESCAPE → if _view != "root": _show_root() else _close(); get_viewport().set_input_as_handled().

  • Step 4: --import + boot-check. Run the rewritten shop tests — expect PASS. Full suite + count

    • determinism (unchanged). Commit ui/meta_shop_panel.gd tests/test_meta_shop.gd tests/test_meta_shop_panel.gd.

Task 4: ResultsPanel → Redeploy / Shop / Main Menu death screen

Section titled “Task 4: ResultsPanel → Redeploy / Shop / Main Menu death screen”

Files:

  • Modify: ui/results_panel.gd
  • Test: tests/test_results_panel.gd (create)

Interfaces — Produces:

  • signal redeploy_requested, signal shop_requested, signal menu_requested (keep restart_requested for the existing show_victory “Climb again” button — unchanged).

  • show_results(sim, banked_gold) builds the run summary + a 3-button column (Redeploy / Shop / Main Menu) with debounced joypad/JOY_BUTTON_A focus nav (mirror PauseMenu). Each button emits its signal.

  • refocus() — re-grab focus on the current selection (used when returning from the shop).

  • Step 1: Write failing tests (tests/test_results_panel.gd)

extends GutTest
func _panel() -> ResultsPanel:
var p := ResultsPanel.new(); add_child_autofree(p); return p
func test_results_has_three_buttons() -> void:
var p := _panel(); await get_tree().process_frame
var sim := Sim.new(1, SimContentFixture.db())
p.show_results(sim, 0)
assert_eq(p._buttons.size(), 3, "Redeploy / Shop / Main Menu")
func test_buttons_emit_their_signals() -> void:
var p := _panel(); await get_tree().process_frame
var sim := Sim.new(1, SimContentFixture.db())
p.show_results(sim, 0)
watch_signals(p)
p._buttons[0].emit_signal("pressed") # Redeploy
p._buttons[1].emit_signal("pressed") # Shop
p._buttons[2].emit_signal("pressed") # Main Menu
assert_signal_emitted(p, "redeploy_requested")
assert_signal_emitted(p, "shop_requested")
assert_signal_emitted(p, "menu_requested")
  • Step 2: Run — expect FAIL.
  • Step 3: Rewrite show_results to build the summary label + three _make_btn(text, signal) buttons into a _buttons: Array[Button], with the PauseMenu-style _move/_focus_sel/_input (debounced d-pad nav + JOY_BUTTON_A/Enter confirm emitting the focused button’s pressed). Add refocus(). Leave show_victory (and its restart_requested button) intact.
  • Step 4: --import + boot-check. Run — expect PASS. Full suite + count + determinism. Commit ui/results_panel.gd tests/test_results_panel.gd.

Files:

  • Modify: ui/start_menu.gd
  • Test: tests/test_start_menu.gd (create)

Interfaces — Produces: signal shop_requested. A SHOP footer button (styled like the existing “Remote Control” footer) added to _cards; activating it emits shop_requested (route it in _input’s confirm + its pressed handler via a set_meta("mode", "shop") sentinel, like “remote”).

  • Step 1: Write failing test (tests/test_start_menu.gd)
extends GutTest
func test_shop_entry_emits_shop_requested() -> void:
var m := StartMenu.new(); add_child_autofree(m); await get_tree().process_frame
watch_signals(m)
# find the shop card by its mode meta and fire it
var fired := false
for c in m._cards:
if String(c.get_meta("mode", "")) == "shop":
c.emit_signal("pressed"); fired = true; break
assert_true(fired, "a SHOP entry exists")
assert_signal_emitted(m, "shop_requested")
  • Step 2: Run — expect FAIL.
  • Step 3: Add the shop_requested signal + a SHOP footer button (set_meta("mode","shop"), pressedshop_requested.emit()); in _input confirm, route sel_mode == "shop" → emit.
  • Step 4: --import + boot-check. Run — expect PASS. Full suite + count + determinism. Commit ui/start_menu.gd tests/test_start_menu.gd.

Files:

  • Modify: ui/pause_menu.gd
  • Test: tests/test_pause_menu.gd (create)

Interfaces — Produces: signal shop_requested. A “Shop” button added to _buttons (between Resume and Back to Start Menu), pressedshop_requested.emit().

  • Step 1: Write failing test (tests/test_pause_menu.gd)
extends GutTest
func test_shop_button_emits_shop_requested() -> void:
var p := PauseMenu.new(); add_child_autofree(p); await get_tree().process_frame
watch_signals(p)
var found := false
for b in p._buttons:
if b.text == "Shop":
b.emit_signal("pressed"); found = true; break
assert_true(found, "a Shop button exists")
assert_signal_emitted(p, "shop_requested")
  • Step 2: Run — expect FAIL.
  • Step 3: Add the signal + _make_btn("Shop", func(): shop_requested.emit()) (keep default focus on Resume — re-check the _sel index if button order shifts).
  • Step 4: --import + boot-check. Run — expect PASS. Full suite + count + determinism. Commit ui/pause_menu.gd tests/test_pause_menu.gd.

Task 7: main.gd wiring — one _open_shop(return_to), three entry points

Section titled “Task 7: main.gd wiring — one _open_shop(return_to), three entry points”

Files:

  • Modify: main.gd
  • Verify: boot smoke + playtest (UI wiring; no unit test for main.gd’s scene assembly).

Interfaces — Consumes: all the signals from Tasks 3–6. Produces: the routing.

  • Step 1: Game-over → Results (not the shop). Replace the meta_shop.show_shop(...) branch:
if sim.game_over:
_bank_run()
audio.game_over()
results.show_results(sim, meta.banked_gold if meta != null else -1)
  • Step 2: Add _open_shop + wire Results signals in the panel-setup block (replace the meta_shop.restart_requested.connect(_new_run) line; ResultsPanel is created at main.gd:432):
func _open_shop(return_to: Callable) -> void:
meta_shop.open_shop(meta, sim.content.meta_upgrades() if sim != null else meta_defs(), return_to)
# meta upgrades when no run is live (start-menu shop): load from the committed bible once.
func meta_defs() -> Array:
return _menu_content().meta_upgrades() # _menu_content(): cache a ContentLoader.load_from_path

Wire:

  • results.redeploy_requested.connect(_new_run)

  • results.shop_requested.connect(func() -> void: results.visible = false; _open_shop(func() -> void: results.visible = true; results.refocus()))

  • results.menu_requested.connect(_return_to_menu)

  • meta_shop no longer connects restart_requested (removed).

  • Step 3: Start-menu + pause-menu entry.

    • In _show_start_menu: start_menu.shop_requested.connect(func() -> void: start_menu.visible = false; _open_shop(func() -> void: start_menu.visible = true)). (Recreate-on-return is fine too; whichever restores focus.)
    • In _toggle_pause (where the pause menu is built): pause_menu.shop_requested.connect(func() -> void: pause_menu.visible = false; _open_shop(func() -> void: if pause_menu != null: pause_menu.visible = true)). The run stays paused (_paused_for_menu unchanged).
  • Step 4: Teardown safety. _return_to_menu / _new_run already queue_free non-persistent children incl. meta_shop + results; confirm meta_shop is recreated in the setup block so a reopened run has a fresh panel. The shop’s _return_to callables capture results/start_menu/ pause_menu — ensure they’re still valid when invoked (they are: the shop closes before the run tears down).

  • Step 5: Boot-check (--quit-after 90 | grep "SCRIPT ERROR" empty) + full suite + count + determinism (unchanged). Manual playtest on desktop: start-menu SHOP, pause→Shop mid-run, die→Results→ Shop→back, Redeploy, Main Menu. Commit main.gd.


  • Run the bh-deploy ritual (bump Sim_Const.BUILD, sync main→tvOS gameplay + whole tests/, verify tvOS suite/count, export .pck, xcodebuild, devicectl install/launch). Update the roadmap memory with the new BUILD + “shop overhaul shipped”.
  • Tasks 1–2 are pure data/logic (trivially green). Task 3 is the big rewrite; 4–6 are small per-panel additions; 7 is wiring. Every task is determinism-neutral — the determinism test must stay 1405185210/3122397125 (survival) and 91572468/1173256610 (crystals) throughout; if it ever moves, something leaked into /sim — stop and investigate.
  • After this lands, the Drones category is ready to absorb the drone shop trees (roadmap #3).