Skip to content

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.

  • New, independent git repo at ~/Claude/sovereign/ — run git init before creating any files (~/Claude/.gitignore is 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 are RefCounted, no Node/Engine/FileAccess APIs — keeps the fitting/combat logic headless-testable via GUT, mirroring bullet-heaven’s /sim purity rule.
  • TDD (GUT) for every sim/ file. render/, ui/, and main.gd are 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 sovereign is a placeholder — not final branding.
  • Movement uses Godot’s built-in ui_up/ui_down/ui_left/ui_right input 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_*.gd files run under.

  • Step 1: Create the repo and directory scaffold

Terminal window
mkdir -p ~/Claude/sovereign
cd ~/Claude/sovereign
git init
mkdir -p sim render ui tests
  • Step 2: Copy the GUT addon from bullet-heaven (read-only copy, does not touch the source repo)
Terminal window
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=1280
window/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-flavored
combat loop (paused ship fitting, range-falloff/travel-time weapons, losing
your fit on death) is fun — split out of `~/Claude/bullet-heaven` (Dark
Cosmos) because that direction is a different genre (pause-driven
configuration + persistent stakes), not a deeper mode of a twin-stick
bullet-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
Terminal window
cd ~/Claude/sovereign
git add -A
git 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: int via ModuleDef.Kind enum WEAPON/ARMOR, damage: float, optimal_range: float, falloff_range: float, cooldown: float, projectile_speed: float, armor_bonus: float); ModuleCatalog.all() -> Array[ModuleDef] and ModuleCatalog.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 CombatMath
extends 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 ModuleDef
extends RefCounted
enum Kind { WEAPON, ARMOR }
var id: String
var kind: int
var damage: float
var optimal_range: float
var falloff_range: float
var cooldown: float
var projectile_speed: float
var 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_bonus

Save 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 ModuleCatalog
extends 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 null

Save 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
Terminal window
git add sim/module_def.gd sim/module_catalog.gd sim/combat_math.gd tests/test_combat_math.gd tests/test_module_catalog.gd
git 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]); fields equipped_weapons: Array[String], equipped_armor: Array[String], stash: Array[String]; methods equip(id: String) -> bool, unequip(id: String) -> bool, destroy_equipped_fit() -> void, total_armor_bonus() -> float. Constants FitState.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 FitState
extends RefCounted
const WEAPON_SLOTS := 3
const 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 total

Save 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
Terminal window
git add sim/fit_state.gd tests/test_fit_state.gd
git 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, ModuleDef fields, CombatMath.damage_at_range(...) (Task 2).

  • Produces: ShipState.new(pos: Vector2, max_hp: float, team: int, weapon_ids: Array[String]) with fields pos: Vector2, hp: float, max_hp: float, team: int, weapon_ids: Array[String], alive: bool, method take_damage(amount: float) -> void; Projectile.new(source_pos: Vector2, target: ShipState, damage: float, total_time: float) with current_pos() -> Vector2, progress() -> float; CombatSim.new() with add_ship(ship: ShipState) -> void, tick(dt: float) -> void, fields ships: Array[ShipState], projectiles: Array[Projectile]. Later tasks (render) read sim.ships and sim.projectiles directly 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 ShipState
extends RefCounted
var pos: Vector2
var hp: float
var max_hp: float
var team: int
var 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 = false

Save as sim/ship_state.gd.

  • Step 2: Write Projectile
class_name Projectile
extends RefCounted
var source_pos: Vector2
var target: ShipState
var damage: float
var total_time: float
var 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 CombatSim
extends 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 nearest

Save 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
Terminal window
git add sim/ship_state.gd sim/projectile.gd sim/combat_sim.gd tests/test_combat_sim.gd
git 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) with setup(fit_state: FitState, enemy_count: int) -> void, signals player_died and wave_cleared. Task 6 depends on exactly these two signal names and the setup(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 ShipRender
extends Node2D
var ship: ShipState
var 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 ProjectileRender
extends 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 CombatView
extends Node2D
signal player_died
signal wave_cleared
const PLAYER_MAX_HP := 100.0
const PLAYER_SPEED := 220.0
const ENEMY_MAX_HP := 40.0
const ARENA_CENTER := Vector2(640, 360)
var sim: CombatSim
var player_ship: ShipState
var ship_nodes: Dictionary = {} # ShipState -> ShipRender
var 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
Terminal window
git add render/ship_render.gd render/projectile_render.gd render/combat_view.gd
git 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 FitScreen
extends 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.gd with 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: FitState
var state: int = State.FITTING
var 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.md capturing 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 scheme

Save as PLAYTEST.md at the project root.

  • Step 7: Commit
Terminal window
git add ui/fit_screen.gd main.gd PLAYTEST.md
git commit -m "feat: fit screen + full fitting/combat/wave state machine, playtest notes"

  • 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-only sim/, 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_cleared signal names match between Task 5’s definitions and Task 6’s .connect() calls; ModuleCatalog.find/ModuleDef.kind/ModuleDef.Kind.WEAPON/ARMOR used identically across combat_sim.gd, fit_state.gd, and fit_screen.gd.