Sovereign Prototype (EVE-lite vertical slice) Implementation Plan
Sovereign Prototype (EVE-lite vertical slice) Implementation Plan
Section titled “Sovereign Prototype (EVE-lite vertical slice) 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: Stand up a brand-new Godot project (working codename sovereign, sibling
to bullet-heaven) and build one throwaway single-player vertical slice —
ship fitting, range-falloff/travel-time combat, and fit-loss-on-death — so Chris
can answer whether the EVE-lite core loop is fun before any further investment.
Architecture: A pure-logic sim/ layer (RefCounted, no Node/Engine/File
APIs — same discipline as bullet-heaven’s /sim) holding module data, fit
management, and a small deterministic-enough combat tick (fire/cooldown/
projectile-travel/damage/death). A thin render/ + ui/ layer reads that
state every _physics_process and draws it with primitive shapes (no art) —
verified by playing, not unit tested, matching the existing project’s own
convention (“New code is TDD’d against GUT; render/feel is verified by
playing”). main.gd is a tiny state machine: FITTING → COMBAT → (death → back
to FITTING with a smaller stash, or empty stash → DEFEAT) → (wave cleared →
next wave, or last wave → VICTORY).
Tech Stack: Godot 4.6 / GDScript, GUT 9.6.0 (test addon copied from
bullet-heaven), desktop (macOS) target only for this phase.
Global Constraints
Section titled “Global Constraints”- New, independent git repo at
~/Claude/sovereign/— rungit initbefore creating any files (~/Claude/.gitignoreis a blanket*; a new subfolder must become its own git root immediately, per standing convention). - Godot 4.6, desktop (Mac) only this phase — no tvOS/iOS/mobile work.
sim/files areRefCounted, noNode/Engine/FileAccessAPIs — keeps the fitting/combat logic headless-testable via GUT, mirroringbullet-heaven’s/simpurity rule.- TDD (GUT) for every
sim/file.render/,ui/, andmain.gdare verified by running the game and playing it, not by unit test — this matches the existing project’s stated convention and is a deliberate choice, not a gap. - This is a throwaway validation prototype: one arena, one player ship type, three waves, no persistence, no multiplayer, no 3D, no real art. Scope creep back toward “a real game” is explicitly out of bounds for this plan.
- Working codename
sovereignis a placeholder — not final branding. - Movement uses Godot’s built-in
ui_up/ui_down/ui_left/ui_rightinput actions (arrow keys, mapped by Godot by default) — no custom InputMap setup needed for this phase.
Task 1: Project scaffold + GUT test harness
Section titled “Task 1: Project scaffold + GUT test harness”Files:
- Create:
~/Claude/sovereign/project.godot - Create:
~/Claude/sovereign/main.tscn - Create:
~/Claude/sovereign/main.gd - Create:
~/Claude/sovereign/.gutconfig.json - Create:
~/Claude/sovereign/CLAUDE.md - Copy:
~/Claude/bullet-heaven/addons/gut/→~/Claude/sovereign/addons/gut/ - Create:
~/Claude/sovereign/tests/test_smoke.gd
Interfaces:
-
Produces: a bootable Godot 4.6 project with a working GUT harness that later tasks’
tests/test_*.gdfiles run under. -
Step 1: Create the repo and directory scaffold
mkdir -p ~/Claude/sovereigncd ~/Claude/sovereigngit initmkdir -p sim render ui tests- Step 2: Copy the GUT addon from bullet-heaven (read-only copy, does not touch the source repo)
cp -r ~/Claude/bullet-heaven/addons ~/Claude/sovereign/addons- Step 3: Write
project.godot
config_version=5
[application]
config/name="Sovereign (working title)"config/version="0.1"run/main_scene="res://main.tscn"config/features=PackedStringArray("4.6", "Forward Plus")
[display]
window/size/viewport_width=1280window/size/viewport_height=720
[physics]
common/physics_ticks_per_second=60- Step 4: Write
main.tscn
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://main.gd" id="1"]
[node name="Main" type="Node2D"]script = ExtResource("1")- Step 5: Write a placeholder
main.gd(replaced fully in Task 6)
extends Node2D
func _ready() -> void: pass- Step 6: Write
.gutconfig.json
{ "dirs": ["res://tests"], "include_subdirs": true, "log_level": 1, "should_exit": true, "should_maximize": false}- Step 7: Write a trivial smoke test
extends GutTest
func test_harness_runs() -> void: assert_eq(1 + 1, 2, "GUT harness is wired up correctly")Save as tests/test_smoke.gd.
- Step 8: Run the test harness
Run: cd ~/Claude/sovereign && godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: exit code 0, output includes test_harness_runs passing and a summary showing 1 test, 1 pass, 0 fail.
- Step 9: Headless boot smoke
Run: godot --headless --path . --quit-after 5
Expected: no SCRIPT ERROR in stderr, clean exit.
- Step 10: Write a short project
CLAUDE.md
# Sovereign (working title) — Project Instructions
A throwaway single-player prototype testing whether an EVE Online-flavoredcombat loop (paused ship fitting, range-falloff/travel-time weapons, losingyour fit on death) is fun — split out of `~/Claude/bullet-heaven` (DarkCosmos) because that direction is a different genre (pause-drivenconfiguration + persistent stakes), not a deeper mode of a twin-stickbullet-heaven. Design rationale: `~/Claude/bullet-heaven/docs/superpowers/specs/2026-07-04-project-split-design.md`.
Godot 4.6, desktop (Mac) only for this phase. `sim/` is pure `RefCounted`logic (no Node/Engine/File APIs), TDD'd via GUT. `render/`/`ui/`/`main.gd`are verified by playing, not unit tested.
Run tests: `godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit`Play it: `godot --path .` from this directory, or open in the editor and press F5.- Step 11: Commit
cd ~/Claude/sovereigngit add -Agit commit -m "chore: scaffold Sovereign prototype project with GUT harness"Task 2: Module data + range-falloff damage math
Section titled “Task 2: Module data + range-falloff damage math”Files:
- Create:
sim/module_def.gd - Create:
sim/module_catalog.gd - Create:
sim/combat_math.gd - Test:
tests/test_combat_math.gd - Test:
tests/test_module_catalog.gd
Interfaces:
-
Produces:
ModuleDef(fields:id: String,kind: intviaModuleDef.KindenumWEAPON/ARMOR,damage: float,optimal_range: float,falloff_range: float,cooldown: float,projectile_speed: float,armor_bonus: float);ModuleCatalog.all() -> Array[ModuleDef]andModuleCatalog.find(id: String) -> ModuleDef(null if not found);CombatMath.damage_at_range(base_damage: float, optimal_range: float, falloff_range: float, distance: float) -> float. -
Step 1: Write the failing combat-math tests
extends GutTest
func test_full_damage_within_optimal_range() -> void: var dmg := CombatMath.damage_at_range(20.0, 100.0, 50.0, 80.0) assert_eq(dmg, 20.0, "no falloff inside optimal range")
func test_damage_decays_linearly_past_optimal() -> void: var dmg := CombatMath.damage_at_range(20.0, 100.0, 50.0, 125.0) assert_almost_eq(dmg, 10.0, 0.001, "halfway through the falloff band = half damage")
func test_damage_floors_at_zero_past_falloff_band() -> void: var dmg := CombatMath.damage_at_range(20.0, 100.0, 50.0, 200.0) assert_eq(dmg, 0.0, "beyond optimal+falloff, damage floors at zero")Save as tests/test_combat_math.gd.
- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_combat_math.gd -gexit
Expected: FAIL — Identifier "CombatMath" not declared.
- Step 3: Implement
CombatMath
class_name CombatMathextends RefCounted
const MIN_FALLOFF_MULT := 0.0
static func damage_at_range(base_damage: float, optimal_range: float, falloff_range: float, distance: float) -> float: if distance <= optimal_range: return base_damage if falloff_range <= 0.0: return 0.0 var over := distance - optimal_range if over >= falloff_range: return base_damage * MIN_FALLOFF_MULT var frac := over / falloff_range return base_damage * (1.0 - frac)Save as sim/combat_math.gd.
- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_combat_math.gd -gexit
Expected: 3 passed, 0 failed.
- Step 5: Write
ModuleDef
class_name ModuleDefextends RefCounted
enum Kind { WEAPON, ARMOR }
var id: Stringvar kind: intvar damage: floatvar optimal_range: floatvar falloff_range: floatvar cooldown: floatvar projectile_speed: floatvar armor_bonus: float
func _init(p_id: String, p_kind: int, p_damage: float, p_optimal_range: float, p_falloff_range: float, p_cooldown: float, p_projectile_speed: float, p_armor_bonus: float = 0.0) -> void: id = p_id kind = p_kind damage = p_damage optimal_range = p_optimal_range falloff_range = p_falloff_range cooldown = p_cooldown projectile_speed = p_projectile_speed armor_bonus = p_armor_bonusSave as sim/module_def.gd.
- Step 6: Write the failing catalog test
extends GutTest
func test_catalog_has_five_starting_modules() -> void: assert_eq(ModuleCatalog.all().size(), 5, "blaster, railgun, autocannon, plate, shield_booster")
func test_find_returns_null_for_unknown_id() -> void: assert_null(ModuleCatalog.find("nonexistent"), "unknown ids return null, not an error")
func test_find_returns_matching_module() -> void: var def := ModuleCatalog.find("blaster") assert_eq(def.id, "blaster") assert_eq(def.kind, ModuleDef.Kind.WEAPON)Save as tests/test_module_catalog.gd.
- Step 7: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_module_catalog.gd -gexit
Expected: FAIL — Identifier "ModuleCatalog" not declared.
- Step 8: Implement
ModuleCatalog
class_name ModuleCatalogextends RefCounted
static func all() -> Array[ModuleDef]: var list: Array[ModuleDef] = [] list.append(ModuleDef.new("blaster", ModuleDef.Kind.WEAPON, 12.0, 150.0, 100.0, 0.5, 900.0)) list.append(ModuleDef.new("railgun", ModuleDef.Kind.WEAPON, 20.0, 350.0, 150.0, 1.2, 1400.0)) list.append(ModuleDef.new("autocannon", ModuleDef.Kind.WEAPON, 6.0, 90.0, 60.0, 0.25, 1100.0)) list.append(ModuleDef.new("plate", ModuleDef.Kind.ARMOR, 0.0, 0.0, 0.0, 0.0, 0.0, 40.0)) list.append(ModuleDef.new("shield_booster", ModuleDef.Kind.ARMOR, 0.0, 0.0, 0.0, 0.0, 0.0, 25.0)) return list
static func find(id: String) -> ModuleDef: for m in all(): if m.id == id: return m return nullSave as sim/module_catalog.gd.
- Step 9: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: all tests so far pass (smoke + combat_math + module_catalog).
- Step 10: Commit
git add sim/module_def.gd sim/module_catalog.gd sim/combat_math.gd tests/test_combat_math.gd tests/test_module_catalog.gdgit commit -m "feat: module catalog + range-falloff damage math"Task 3: Fit state (equip / unequip / lose-on-death)
Section titled “Task 3: Fit state (equip / unequip / lose-on-death)”Files:
- Create:
sim/fit_state.gd - Test:
tests/test_fit_state.gd
Interfaces:
-
Consumes:
ModuleCatalog.find(id) -> ModuleDef,ModuleDef.kind(Task 2). -
Produces:
FitState.new(starting_stash: Array[String]); fieldsequipped_weapons: Array[String],equipped_armor: Array[String],stash: Array[String]; methodsequip(id: String) -> bool,unequip(id: String) -> bool,destroy_equipped_fit() -> void,total_armor_bonus() -> float. ConstantsFitState.WEAPON_SLOTS = 3,FitState.ARMOR_SLOTS = 1. -
Step 1: Write the failing tests
extends GutTest
func _starting_stash() -> Array[String]: var s: Array[String] = ["blaster", "blaster", "railgun", "autocannon", "plate", "shield_booster"] return s
func test_equip_moves_module_from_stash_to_slot() -> void: var fit := FitState.new(_starting_stash()) var before := fit.stash.count("blaster") assert_true(fit.equip("blaster"), "equips from stash") assert_eq(fit.equipped_weapons, ["blaster"], "blaster now equipped") assert_eq(fit.stash.count("blaster"), before - 1, "one blaster consumed from stash")
func test_cannot_equip_more_than_weapon_slots() -> void: var fit := FitState.new(_starting_stash()) fit.equip("blaster") fit.equip("railgun") fit.equip("autocannon") assert_false(fit.equip("blaster"), "4th weapon rejected, only 3 slots")
func test_cannot_equip_module_not_in_stash() -> void: var fit := FitState.new(_starting_stash()) fit.stash.clear() assert_false(fit.equip("blaster"), "nothing to equip once stash is empty")
func test_unequip_returns_module_to_stash() -> void: var fit := FitState.new(_starting_stash()) fit.equip("railgun") assert_true(fit.unequip("railgun")) assert_true(fit.stash.has("railgun"), "unequipped module returns to stash") assert_eq(fit.equipped_weapons.size(), 0)
func test_death_destroys_equipped_fit_but_not_stash() -> void: var fit := FitState.new(_starting_stash()) fit.equip("blaster") fit.equip("plate") fit.destroy_equipped_fit() assert_eq(fit.equipped_weapons.size(), 0, "equipped weapons destroyed on death") assert_eq(fit.equipped_armor.size(), 0, "equipped armor destroyed on death") assert_true(fit.stash.has("railgun"), "stash untouched by death")
func test_total_armor_bonus_sums_equipped_armor() -> void: var fit := FitState.new(_starting_stash()) fit.equip("plate") assert_almost_eq(fit.total_armor_bonus(), 40.0, 0.001)Save as tests/test_fit_state.gd.
- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_fit_state.gd -gexit
Expected: FAIL — Identifier "FitState" not declared.
- Step 3: Implement
FitState
class_name FitStateextends RefCounted
const WEAPON_SLOTS := 3const ARMOR_SLOTS := 1
var equipped_weapons: Array[String] = []var equipped_armor: Array[String] = []var stash: Array[String] = []
func _init(starting_stash: Array[String]) -> void: stash = starting_stash.duplicate()
func can_equip(kind: int) -> bool: if kind == ModuleDef.Kind.WEAPON: return equipped_weapons.size() < WEAPON_SLOTS return equipped_armor.size() < ARMOR_SLOTS
func equip(module_id: String) -> bool: if not stash.has(module_id): return false var def := ModuleCatalog.find(module_id) if def == null or not can_equip(def.kind): return false stash.erase(module_id) if def.kind == ModuleDef.Kind.WEAPON: equipped_weapons.append(module_id) else: equipped_armor.append(module_id) return true
func unequip(module_id: String) -> bool: if equipped_weapons.has(module_id): equipped_weapons.erase(module_id) stash.append(module_id) return true if equipped_armor.has(module_id): equipped_armor.erase(module_id) stash.append(module_id) return true return false
func destroy_equipped_fit() -> void: equipped_weapons.clear() equipped_armor.clear()
func total_armor_bonus() -> float: var total := 0.0 for id in equipped_armor: var def := ModuleCatalog.find(id) if def != null: total += def.armor_bonus return totalSave as sim/fit_state.gd.
- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: all tests pass (smoke + combat_math + module_catalog + fit_state).
- Step 5: Commit
git add sim/fit_state.gd tests/test_fit_state.gdgit commit -m "feat: fit state with equip/unequip and lose-fit-on-death"Task 4: Ship state, projectiles, and the combat tick
Section titled “Task 4: Ship state, projectiles, and the combat tick”Files:
- Create:
sim/ship_state.gd - Create:
sim/projectile.gd - Create:
sim/combat_sim.gd - Test:
tests/test_combat_sim.gd
Interfaces:
-
Consumes:
ModuleCatalog.find(id) -> ModuleDef,ModuleDeffields,CombatMath.damage_at_range(...)(Task 2). -
Produces:
ShipState.new(pos: Vector2, max_hp: float, team: int, weapon_ids: Array[String])with fieldspos: Vector2,hp: float,max_hp: float,team: int,weapon_ids: Array[String],alive: bool, methodtake_damage(amount: float) -> void;Projectile.new(source_pos: Vector2, target: ShipState, damage: float, total_time: float)withcurrent_pos() -> Vector2,progress() -> float;CombatSim.new()withadd_ship(ship: ShipState) -> void,tick(dt: float) -> void, fieldsships: Array[ShipState],projectiles: Array[Projectile]. Later tasks (render) readsim.shipsandsim.projectilesdirectly every frame — do not rename these fields. -
Step 1: Write
ShipState(no test needed — it’s a plain data holder exercised by the combat_sim tests below)
class_name ShipStateextends RefCounted
var pos: Vector2var hp: floatvar max_hp: floatvar team: intvar weapon_ids: Array[String] = []var weapon_cooldowns: Array[float] = []var alive: bool = true
func _init(p_pos: Vector2, p_max_hp: float, p_team: int, p_weapon_ids: Array[String]) -> void: pos = p_pos max_hp = p_max_hp hp = p_max_hp team = p_team weapon_ids = p_weapon_ids.duplicate() weapon_cooldowns = [] for i in range(weapon_ids.size()): weapon_cooldowns.append(0.0)
func take_damage(amount: float) -> void: hp = maxf(0.0, hp - amount) if hp <= 0.0: alive = falseSave as sim/ship_state.gd.
- Step 2: Write
Projectile
class_name Projectileextends RefCounted
var source_pos: Vector2var target: ShipStatevar damage: floatvar total_time: floatvar time_remaining: float
func _init(p_source_pos: Vector2, p_target: ShipState, p_damage: float, p_total_time: float) -> void: source_pos = p_source_pos target = p_target damage = p_damage total_time = p_total_time time_remaining = p_total_time
func progress() -> float: if total_time <= 0.0: return 1.0 return clampf(1.0 - (time_remaining / total_time), 0.0, 1.0)
func current_pos() -> Vector2: return source_pos.lerp(target.pos, progress())Save as sim/projectile.gd.
- Step 3: Write the failing combat_sim tests
extends GutTest
func test_weapon_does_not_fire_when_target_out_of_range() -> void: var sim := CombatSim.new() var player := ShipState.new(Vector2.ZERO, 100.0, 0, ["blaster"]) var enemy := ShipState.new(Vector2(500, 0), 100.0, 1, []) sim.add_ship(player) sim.add_ship(enemy) sim.tick(1.0) assert_eq(sim.projectiles.size(), 0, "blaster max range is 250 (150 optimal + 100 falloff); 500 is out of range")
func test_weapon_fires_within_range_and_respects_cooldown() -> void: var sim := CombatSim.new() var player := ShipState.new(Vector2.ZERO, 100.0, 0, ["blaster"]) var enemy := ShipState.new(Vector2(100, 0), 100.0, 1, []) sim.add_ship(player) sim.add_ship(enemy) sim.tick(0.1) assert_eq(sim.projectiles.size(), 1, "one shot fired on first tick in range") sim.tick(0.1) assert_eq(sim.projectiles.size(), 1, "blaster cooldown (0.5s) blocks a second shot 0.1s later")
func test_projectile_deals_damage_after_travel_time_elapses() -> void: var sim := CombatSim.new() var player := ShipState.new(Vector2.ZERO, 100.0, 0, ["blaster"]) var enemy := ShipState.new(Vector2(90, 0), 100.0, 1, []) sim.add_ship(player) sim.add_ship(enemy) sim.tick(0.1) # fires; travel_time = 90/900 = 0.1s assert_eq(enemy.hp, 100.0, "projectile still in flight, no damage yet") sim.tick(0.1) assert_lt(enemy.hp, 100.0, "projectile has landed, damage applied")
func test_ship_dies_when_hp_reaches_zero() -> void: var sim := CombatSim.new() var player := ShipState.new(Vector2.ZERO, 100.0, 0, ["blaster"]) var enemy := ShipState.new(Vector2(50, 0), 5.0, 1, []) sim.add_ship(player) sim.add_ship(enemy) sim.tick(0.1) sim.tick(0.1) assert_false(enemy.alive, "enemy destroyed once hp <= 0 (12 dmg > 5 hp)")
func test_dead_ships_are_not_targeted() -> void: var sim := CombatSim.new() var player := ShipState.new(Vector2.ZERO, 100.0, 0, ["blaster"]) var dead_enemy := ShipState.new(Vector2(50, 0), 5.0, 1, []) dead_enemy.alive = false var far_enemy := ShipState.new(Vector2(1000, 0), 100.0, 1, []) sim.add_ship(player) sim.add_ship(dead_enemy) sim.add_ship(far_enemy) sim.tick(0.1) assert_eq(sim.projectiles.size(), 0, "the only living enemy is out of range; the dead one must be ignored, not targeted")Save as tests/test_combat_sim.gd.
- Step 4: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_combat_sim.gd -gexit
Expected: FAIL — Identifier "CombatSim" not declared.
- Step 5: Implement
CombatSim
class_name CombatSimextends RefCounted
var ships: Array[ShipState] = []var projectiles: Array[Projectile] = []
func add_ship(ship: ShipState) -> void: ships.append(ship)
func tick(dt: float) -> void: _advance_projectiles(dt) _fire_weapons(dt)
func _fire_weapons(dt: float) -> void: for ship in ships: if not ship.alive: continue var target := _nearest_enemy(ship) if target == null: continue var distance := ship.pos.distance_to(target.pos) for i in range(ship.weapon_ids.size()): ship.weapon_cooldowns[i] = maxf(0.0, ship.weapon_cooldowns[i] - dt) if ship.weapon_cooldowns[i] > 0.0: continue var def := ModuleCatalog.find(ship.weapon_ids[i]) if def == null or def.kind != ModuleDef.Kind.WEAPON: continue var max_range := def.optimal_range + def.falloff_range if distance > max_range: continue var dmg := CombatMath.damage_at_range(def.damage, def.optimal_range, def.falloff_range, distance) var travel_time := distance / def.projectile_speed projectiles.append(Projectile.new(ship.pos, target, dmg, travel_time)) ship.weapon_cooldowns[i] = def.cooldown
func _advance_projectiles(dt: float) -> void: var still_flying: Array[Projectile] = [] for p in projectiles: p.time_remaining -= dt if p.time_remaining <= 0.0: if p.target.alive: p.target.take_damage(p.damage) else: still_flying.append(p) projectiles = still_flying
func _nearest_enemy(ship: ShipState) -> ShipState: var nearest: ShipState = null var nearest_dist := INF for other in ships: if other.team == ship.team or not other.alive: continue var d := ship.pos.distance_to(other.pos) if d < nearest_dist: nearest_dist = d nearest = other return nearestSave as sim/combat_sim.gd.
- Step 6: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: all tests pass (10 across smoke/combat_math/module_catalog/fit_state/combat_sim).
- Step 7: Commit
git add sim/ship_state.gd sim/projectile.gd sim/combat_sim.gd tests/test_combat_sim.gdgit commit -m "feat: ship state, projectile travel time, and the combat tick"Task 5: Render layer — ships and projectiles on screen
Section titled “Task 5: Render layer — ships and projectiles on screen”Files:
- Create:
render/ship_render.gd - Create:
render/projectile_render.gd - Create:
render/combat_view.gd
Interfaces:
- Consumes:
CombatSim,ShipState,Projectile,FitState.equipped_weapons,FitState.total_armor_bonus()(Tasks 2-4). - Produces:
CombatView(class_name CombatView,extends Node2D) withsetup(fit_state: FitState, enemy_count: int) -> void, signalsplayer_diedandwave_cleared. Task 6 depends on exactly these two signal names and thesetup(fit_state, enemy_count)signature.
This task has no unit tests — it’s manually verified by running the game, per the Global Constraints.
- Step 1: Write
ShipRender
class_name ShipRenderextends Node2D
var ship: ShipStatevar color: Color = Color.WHITE
func _draw() -> void: if ship == null: return draw_circle(ship.pos, 16.0, color if ship.alive else Color(0.3, 0.3, 0.3)) if ship.alive: var hp_frac := ship.hp / ship.max_hp var bar_pos := ship.pos + Vector2(-20, -28) draw_rect(Rect2(bar_pos, Vector2(40, 5)), Color(0.2, 0.2, 0.2)) draw_rect(Rect2(bar_pos, Vector2(40 * hp_frac, 5)), Color(0.2, 1.0, 0.3))Save as render/ship_render.gd.
- Step 2: Write
ProjectileRender
class_name ProjectileRenderextends Node2D
var projectile: Projectile
func _draw() -> void: if projectile == null: return draw_circle(projectile.current_pos(), 4.0, Color(1.0, 0.9, 0.3))Save as render/projectile_render.gd.
- Step 3: Write
CombatView
class_name CombatViewextends Node2D
signal player_diedsignal wave_cleared
const PLAYER_MAX_HP := 100.0const PLAYER_SPEED := 220.0const ENEMY_MAX_HP := 40.0const ARENA_CENTER := Vector2(640, 360)
var sim: CombatSimvar player_ship: ShipStatevar ship_nodes: Dictionary = {} # ShipState -> ShipRendervar projectile_nodes: Dictionary = {} # Projectile -> ProjectileRender
func setup(fit_state: FitState, enemy_count: int) -> void: sim = CombatSim.new() player_ship = ShipState.new(ARENA_CENTER, PLAYER_MAX_HP + fit_state.total_armor_bonus(), 0, fit_state.equipped_weapons) sim.add_ship(player_ship) _spawn_ship_render(player_ship, Color(0.2, 0.8, 1.0))
for i in range(enemy_count): var angle := TAU * float(i) / float(enemy_count) var offset := Vector2(cos(angle), sin(angle)) * 300.0 var enemy := ShipState.new(ARENA_CENTER + offset, ENEMY_MAX_HP, 1, ["autocannon"]) sim.add_ship(enemy) _spawn_ship_render(enemy, Color(1.0, 0.3, 0.3))
func _spawn_ship_render(ship: ShipState, color: Color) -> void: var node := ShipRender.new() node.ship = ship node.color = color add_child(node) ship_nodes[ship] = node
func _physics_process(delta: float) -> void: if player_ship == null or not player_ship.alive: return _handle_input(delta) sim.tick(delta) _sync_render() _check_end_conditions()
func _handle_input(delta: float) -> void: var dir := Vector2.ZERO if Input.is_action_pressed("ui_up"): dir.y -= 1 if Input.is_action_pressed("ui_down"): dir.y += 1 if Input.is_action_pressed("ui_left"): dir.x -= 1 if Input.is_action_pressed("ui_right"): dir.x += 1 if dir != Vector2.ZERO: player_ship.pos += dir.normalized() * PLAYER_SPEED * delta
func _sync_render() -> void: for node in ship_nodes.values(): node.queue_redraw() _sync_projectiles()
func _sync_projectiles() -> void: for proj in projectile_nodes.keys(): if not sim.projectiles.has(proj): projectile_nodes[proj].queue_free() projectile_nodes.erase(proj) for proj in sim.projectiles: if not projectile_nodes.has(proj): var node := ProjectileRender.new() node.projectile = proj add_child(node) projectile_nodes[proj] = node for node in projectile_nodes.values(): node.queue_redraw()
func _check_end_conditions() -> void: if not player_ship.alive: player_died.emit() return var any_enemy_alive := false for ship in sim.ships: if ship.team == 1 and ship.alive: any_enemy_alive = true break if not any_enemy_alive: wave_cleared.emit()Save as render/combat_view.gd.
- Step 4: Headless boot smoke (catches script errors before manual play)
Run: godot --headless --path . --quit-after 5
Expected: no SCRIPT ERROR in stderr.
- Step 5: Commit
git add render/ship_render.gd render/projectile_render.gd render/combat_view.gdgit commit -m "feat: render layer for ships and traveling projectiles"Task 6: Fit screen, full game-state orchestration, and the playtest
Section titled “Task 6: Fit screen, full game-state orchestration, and the playtest”Files:
- Create:
ui/fit_screen.gd - Modify:
main.gd(replace the Task 1 placeholder entirely) - Create:
PLAYTEST.md
Interfaces:
- Consumes:
FitState(Task 3),CombatView.setup/player_died/wave_cleared(Task 5),ModuleCatalog/ModuleDef(Task 2).
This task has no unit tests — verified by playing, per Global Constraints.
- Step 1: Write
FitScreen
class_name FitScreenextends Control
signal confirmed
var fit_state: FitState
func _ready() -> void: custom_minimum_size = Vector2(1280, 720) _rebuild()
func _rebuild() -> void: for child in get_children(): remove_child(child) child.queue_free()
var vbox := VBoxContainer.new() vbox.position = Vector2(60, 60) add_child(vbox)
var title := Label.new() title.text = "FIT YOUR SHIP" vbox.add_child(title)
var equipped_label := Label.new() equipped_label.text = "Equipped weapons: %s | armor: %s" % [str(fit_state.equipped_weapons), str(fit_state.equipped_armor)] vbox.add_child(equipped_label)
for module_id in fit_state.stash: var def := ModuleCatalog.find(module_id) if def == null: continue var btn := Button.new() btn.text = "Equip %s (%s)" % [module_id, "weapon" if def.kind == ModuleDef.Kind.WEAPON else "armor"] btn.pressed.connect(_on_equip_pressed.bind(module_id)) vbox.add_child(btn)
for module_id in fit_state.equipped_weapons + fit_state.equipped_armor: var btn := Button.new() btn.text = "Unequip %s" % module_id btn.pressed.connect(_on_unequip_pressed.bind(module_id)) vbox.add_child(btn)
var launch_btn := Button.new() launch_btn.text = "LAUNCH" launch_btn.disabled = fit_state.equipped_weapons.is_empty() launch_btn.pressed.connect(func(): confirmed.emit()) vbox.add_child(launch_btn)
func _on_equip_pressed(module_id: String) -> void: fit_state.equip(module_id) _rebuild()
func _on_unequip_pressed(module_id: String) -> void: fit_state.unequip(module_id) _rebuild()Save as ui/fit_screen.gd.
- Step 2: Replace
main.gdwith the full state machine
extends Node2D
enum State { FITTING, COMBAT, VICTORY, DEFEAT }
const STARTING_STASH: Array[String] = ["blaster", "blaster", "railgun", "autocannon", "plate", "shield_booster"]const WAVE_ENEMY_COUNTS := [2, 3, 3]
var fit_state: FitStatevar state: int = State.FITTINGvar wave_index: int = 0
func _ready() -> void: fit_state = FitState.new(STARTING_STASH) _enter_fitting()
func _clear_children() -> void: for child in get_children(): remove_child(child) child.queue_free()
func _enter_fitting() -> void: state = State.FITTING _clear_children() var fit_screen := FitScreen.new() fit_screen.fit_state = fit_state fit_screen.confirmed.connect(_on_fit_confirmed) add_child(fit_screen)
func _on_fit_confirmed() -> void: _enter_combat()
func _enter_combat() -> void: state = State.COMBAT _clear_children() var combat_view := CombatView.new() combat_view.setup(fit_state, WAVE_ENEMY_COUNTS[wave_index]) combat_view.player_died.connect(_on_player_died) combat_view.wave_cleared.connect(_on_wave_cleared) add_child(combat_view)
func _on_player_died() -> void: fit_state.destroy_equipped_fit() if fit_state.stash.is_empty(): _enter_defeat() else: _enter_fitting()
func _on_wave_cleared() -> void: wave_index += 1 if wave_index >= WAVE_ENEMY_COUNTS.size(): _enter_victory() else: _enter_fitting()
func _enter_victory() -> void: state = State.VICTORY _clear_children() var label := Label.new() label.text = "ALL WAVES CLEARED — VICTORY" label.position = Vector2(440, 340) add_child(label)
func _enter_defeat() -> void: state = State.DEFEAT _clear_children() var label := Label.new() label.text = "OUT OF SPARE MODULES — DEFEAT" label.position = Vector2(400, 340) add_child(label)Save as main.gd (overwriting the Task 1 placeholder).
- Step 3: Headless boot smoke
Run: godot --headless --path . --quit-after 10
Expected: no SCRIPT ERROR in stderr.
- Step 4: Run the full test suite one more time
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: all tests still pass (this task touched no sim/ file, so this is a regression check).
- Step 5: Play it and manually verify the loop end-to-end
Run: godot --path . from ~/Claude/sovereign (or open the project in the
editor and press F5). Confirm: the fit screen lets you equip up to 3 weapons +
1 armor from the stash and disables LAUNCH with zero weapons equipped;
launching drops you into an arena where your ship (cyan) auto-fires at the
nearest red enemy once in range, with a visible traveling shot rather than an
instant hit; taking an enemy’s autocannon fire lowers your HP bar; dying wipes
your equipped fit and returns you to the fit screen with a smaller stash, or
to the DEFEAT screen if the stash is empty; clearing all 3 waves shows VICTORY.
- Step 6: Write
PLAYTEST.mdcapturing the validation questions
# Sovereign prototype — playtest notes
Play a few runs (`godot --path .`), then answer these three questions from`docs/superpowers/specs/2026-07-04-project-split-design.md`:
1. **Fitting:** did pausing to equip weapons/armor before a fight feel engaging, or like friction you wanted to skip?2. **Combat feel:** did range-falloff + travel-time weapons feel good, or did it feel like reduced responsiveness compared to an arcade twin-stick game?3. **Stakes:** did losing your fit on death feel like meaningful stakes worth building a whole economy around, or did it just feel punishing?
Notes:---
## Verdict- [ ] Keep investing (persistent economy, more ships, multiplayer, then a touch/TV pass, then maybe 3D)- [ ] Pivot the concept (which part didn't work?)- [ ] Fold it — the twin-stick genre is the better fit for this control schemeSave as PLAYTEST.md at the project root.
- Step 7: Commit
git add ui/fit_screen.gd main.gd PLAYTEST.mdgit commit -m "feat: fit screen + full fitting/combat/wave state machine, playtest notes"Self-Review
Section titled “Self-Review”- Spec coverage: fitting (Task 3 + 6), range-falloff/travel-time combat
(Task 2 + 4), fit-loss-on-death (Task 3, wired in Task 6), desktop-first
scope (Global Constraints, arrow-key input), reused sim architecture pattern
(
RefCounted-onlysim/, GUT harness copied in Task 1) — all covered. The spec’s phase-1 non-goals (persistent economy, multiplayer, mining, 3D, touch/ TV) are not implemented anywhere in this plan, matching the spec. - Placeholder scan: no TBD/TODO markers; every step has complete code.
- Type consistency:
CombatView.setup(fit_state: FitState, enemy_count: int)matches its Task 6 call site;player_died/wave_clearedsignal names match between Task 5’s definitions and Task 6’s.connect()calls;ModuleCatalog.find/ModuleDef.kind/ModuleDef.Kind.WEAPON/ARMORused identically acrosscombat_sim.gd,fit_state.gd, andfit_screen.gd.