Bullet Heaven — Milestone 1 (Playable Core) Implementation Plan
Bullet Heaven — Milestone 1 (Playable Core) Implementation Plan
Section titled “Bullet Heaven — Milestone 1 (Playable Core) 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: A playable neon bullet-heaven core: move a ship around a bounded arena, one weapon auto-fires and auto-targets, enemies spawn and chase, projectiles kill them via spatial-hash collision, kills drop XP gems, collecting them levels you up and offers a stat-upgrade choice, difficulty escalates over time, and contact death ends the run — all driven by a deterministic, headless-tested simulation rendered with a MultiMesh swarm + glow.
Architecture: One-way data flow Input → Simulation → Render. The simulation is pure GDScript with no rendering dependency, advanced on a fixed timestep using a constant dt (bit-reproducible for future netcode). Many cheap entities (enemies, projectiles, gems) live in flat data-oriented pools rendered via MultiMeshInstance2D; the player is a node. Collision is a hand-rolled uniform-grid spatial hash. All sim logic is unit-tested headlessly with GUT.
Tech Stack: Godot 4.6.3 stable, typed GDScript, GUT 9.6.0 (headless unit tests), MultiMeshInstance2D, WorldEnvironment glow, GPUParticles2D (later milestones).
Global Constraints
Section titled “Global Constraints”- Engine: Godot 4.6.3 stable. Language: typed GDScript (every
var/param/return typed). - Determinism: the sim advances with a constant
DT := 1.0 / 60.0; never use the wall-clockdeltainside sim logic. All randomness flows through oneSeededRng. No engine physics for swarm entities. - Layering:
/simfiles MUST NOT reference/render,/input,/ui, or any visual Node API (noCanvasItem,Node2Ddrawing,get_viewport, etc.)./simdepends only on core types (Vector2,PackedArray*,RefCounted,Resource). This keeps the sim headless-testable and serializable. - Tests: every
/simunit ships with passing GUT tests. Run:godot --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit(exit 0 = pass). - Arena: bounded square,
ARENA_HALF := 2000.0(a 4000×4000 field centred on origin). - Commits: frequent, one per task minimum. Conventional-commit prefixes (
feat:,test:,chore:).
File Structure
Section titled “File Structure”project.godot # engine config: physics tick 60, main scene, mobile renderer.gutconfig.json # GUT headless configaddons/gut/ # GUT 9.6.0 (vendored, not authored)
sim/constants.gd # Sim-wide constants (DT, ARENA_HALF) — class_name Sim_Constsim/seeded_rng.gd # class_name SeededRng — deterministic RNGsim/input_state.gd # class_name InputState — per-player input datasim/entity_pool.gd # class_name EntityPool — flat pool with swap-removesim/spatial_hash.gd # class_name SpatialHash — uniform grid neighbour queriessim/player_state.gd # class_name PlayerState — player data + integrate()sim/spawn_director.gd # class_name SpawnDirector — time-based enemy schedulesim/weapon_pulse.gd # class_name WeaponPulse — auto-fire homing shotsim/upgrades.gd # class_name Upgrades — stat-mod defs + apply()sim/sim.gd # class_name Sim — owns all state, tick(input)
render/swarm_renderer.gd # class_name SwarmRenderer — MultiMeshInstance2D driverrender/arena_background.gd # neon grid background drawinput/input_router.gd # keyboard/gamepad/touch → InputStateui/hud.gd # xp bar, timer, hpui/level_up_panel.gd # upgrade pickerui/results_panel.gd # end-of-run summarymain.gd # wires sim+render+input; _physics_process drives sim
tests/test_seeded_rng.gdtests/test_entity_pool.gdtests/test_spatial_hash.gdtests/test_player_state.gdtests/test_sim_core.gdtests/test_spawn_director.gdtests/test_weapon_pulse.gdtests/test_collision_damage.gdtests/test_xp_levelup.gdtests/test_upgrades.gdtests/test_determinism.gdTask 1: Project setup + GUT harness (green)
Section titled “Task 1: Project setup + GUT harness (green)”Files:
- Create:
project.godot - Create:
.gutconfig.json - Create:
addons/gut/(vendored GUT 9.6.0) - Create:
tests/test_harness.gd
Interfaces:
-
Produces: a working headless test command and the
/sim,/render,/input,/ui,/testsdirectory skeleton. -
Step 1: Create the Godot project file
Create project.godot:
config_version=5
[application]config/name="Bullet Heaven"run/main_scene="res://main.tscn"config/features=PackedStringArray("4.6", "GL Compatibility")
[physics]common/physics_ticks_per_second=60
[rendering]renderer/rendering_method="gl_compatibility"renderer/rendering_method.mobile="gl_compatibility"environment/defaults/default_clear_color=Color(0.03, 0.03, 0.06, 1)(GL Compatibility renderer = widest mobile reach. We can revisit Forward+ for desktop glow quality in a later milestone.)
- Step 2: Vendor GUT 9.6.0
Run:
cd /Users/chris/Claude/bullet-heavengit clone --depth 1 --branch v9.6.0 https://github.com/bitwes/Gut.git /tmp/gut-srcmkdir -p addonscp -R /tmp/gut-src/addons/gut addons/gutrm -rf /tmp/gut-srcls addons/gut/gut_cmdln.gdExpected: prints addons/gut/gut_cmdln.gd (file exists). If the tag v9.6.0 is unavailable, list tags with git ls-remote --tags https://github.com/bitwes/Gut.git and use the latest v9.6.*.
- Step 3: Create GUT config
Create .gutconfig.json:
{ "dirs": ["res://tests"], "include_subdirs": true, "log_level": 1, "should_exit": true, "should_maximize": false}- Step 4: Write the harness test
Create tests/test_harness.gd:
extends GutTest
func test_harness_runs() -> void: assert_eq(1 + 1, 2, "GUT harness should run and assert")- Step 5: Import the project once, then run tests headless
Run:
godot --headless --path . --import 2>/dev/null; \godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit; echo "exit=$?"Expected: GUT runs test_harness_runs, reports 1 passing test, prints exit=0.
- Step 6: Commit
git add -Agit commit -m "chore: Godot 4.6 project + vendored GUT 9.6.0 headless harness"Task 2: Sim constants + SeededRng
Section titled “Task 2: Sim constants + SeededRng”Files:
- Create:
sim/constants.gd - Create:
sim/seeded_rng.gd - Test:
tests/test_seeded_rng.gd
Interfaces:
-
Produces:
Sim_Const.DT : float = 1.0/60.0,Sim_Const.ARENA_HALF : float = 2000.0SeededRng.new(seed: int);randf() -> float(0..1),randf_range(a: float, b: float) -> float,randi_range(a: int, b: int) -> int,rand_unit_dir() -> Vector2(unit-length).
-
Step 1: Create constants
Create sim/constants.gd:
class_name Sim_Constextends RefCounted
const DT: float = 1.0 / 60.0const ARENA_HALF: float = 2000.0- Step 2: Write the failing test
Create tests/test_seeded_rng.gd:
extends GutTest
func test_same_seed_same_sequence() -> void: var a := SeededRng.new(42) var b := SeededRng.new(42) for i in range(20): assert_eq(a.randf(), b.randf(), "same seed must yield identical sequence")
func test_different_seed_differs() -> void: var a := SeededRng.new(1) var b := SeededRng.new(2) var same := true for i in range(20): if a.randf() != b.randf(): same = false assert_false(same, "different seeds should diverge")
func test_rand_unit_dir_is_unit_length() -> void: var r := SeededRng.new(7) for i in range(50): var d := r.rand_unit_dir() assert_almost_eq(d.length(), 1.0, 0.0001, "direction must be unit length")
func test_randi_range_within_bounds() -> void: var r := SeededRng.new(99) for i in range(100): var v := r.randi_range(3, 6) assert_between(v, 3, 6, "randi_range inclusive bounds")- Step 3: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_seeded_rng.gd -gexit
Expected: FAIL — SeededRng not found.
- Step 4: Implement SeededRng
Create sim/seeded_rng.gd:
class_name SeededRngextends RefCounted
var _rng: RandomNumberGenerator
func _init(seed_value: int) -> void: _rng = RandomNumberGenerator.new() _rng.seed = seed_value
func randf() -> float: return _rng.randf()
func randf_range(a: float, b: float) -> float: return _rng.randf_range(a, b)
func randi_range(a: int, b: int) -> int: return _rng.randi_range(a, b)
func rand_unit_dir() -> Vector2: var angle := _rng.randf_range(0.0, TAU) return Vector2(cos(angle), sin(angle))- Step 5: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_seeded_rng.gd -gexit
Expected: PASS — 4 tests.
- Step 6: Commit
git add sim/constants.gd sim/seeded_rng.gd tests/test_seeded_rng.gdgit commit -m "feat(sim): seeded deterministic RNG + sim constants"Task 3: EntityPool (data-oriented swap-remove pool)
Section titled “Task 3: EntityPool (data-oriented swap-remove pool)”Files:
- Create:
sim/entity_pool.gd - Test:
tests/test_entity_pool.gd
Interfaces:
-
Produces
EntityPool.new(capacity: int)with parallel arrays for active entities[0, count):pos: PackedVector2Array,vel: PackedVector2Array,radius: PackedFloat32Array,data: PackedFloat32Array(per-kind scalar: enemy HP, projectile damage, or gem XP value).count: int(active entities).add(p: Vector2, v: Vector2, r: float, d: float) -> intreturns the new index, or -1 if full.remove_at(i: int) -> voidswap-removes: moves the last active entity into sloti, decrementscount.
-
Note for consumers:
remove_atinvalidates indexcount-1’s identity (it moves toi). When iterating-and-removing, iterate backwards or re-check the swapped slot. -
Step 1: Write the failing test
Create tests/test_entity_pool.gd:
extends GutTest
func test_add_increments_count_and_stores_values() -> void: var pool := EntityPool.new(8) assert_eq(pool.count, 0) var i := pool.add(Vector2(10, 20), Vector2(1, 0), 5.0, 3.0) assert_eq(i, 0) assert_eq(pool.count, 1) assert_eq(pool.pos[0], Vector2(10, 20)) assert_eq(pool.vel[0], Vector2(1, 0)) assert_eq(pool.radius[0], 5.0) assert_eq(pool.data[0], 3.0)
func test_add_returns_minus_one_when_full() -> void: var pool := EntityPool.new(2) pool.add(Vector2.ZERO, Vector2.ZERO, 1.0, 1.0) pool.add(Vector2.ZERO, Vector2.ZERO, 1.0, 1.0) assert_eq(pool.add(Vector2.ZERO, Vector2.ZERO, 1.0, 1.0), -1)
func test_swap_remove_keeps_active_contiguous() -> void: var pool := EntityPool.new(8) pool.add(Vector2(1, 1), Vector2.ZERO, 1.0, 10.0) # idx 0 pool.add(Vector2(2, 2), Vector2.ZERO, 1.0, 20.0) # idx 1 pool.add(Vector2(3, 3), Vector2.ZERO, 1.0, 30.0) # idx 2 pool.remove_at(0) assert_eq(pool.count, 2, "count decremented") # last (idx2, data 30) moved into slot 0 assert_eq(pool.pos[0], Vector2(3, 3)) assert_eq(pool.data[0], 30.0) assert_eq(pool.data[1], 20.0)
func test_remove_last_just_decrements() -> void: var pool := EntityPool.new(8) pool.add(Vector2(1, 1), Vector2.ZERO, 1.0, 10.0) pool.add(Vector2(2, 2), Vector2.ZERO, 1.0, 20.0) pool.remove_at(1) assert_eq(pool.count, 1) assert_eq(pool.data[0], 10.0)- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_entity_pool.gd -gexit
Expected: FAIL — EntityPool not found.
- Step 3: Implement EntityPool
Create sim/entity_pool.gd:
class_name EntityPoolextends RefCounted
var capacity: intvar count: int = 0var pos: PackedVector2Arrayvar vel: PackedVector2Arrayvar radius: PackedFloat32Arrayvar data: PackedFloat32Array
func _init(cap: int) -> void: capacity = cap pos.resize(cap) vel.resize(cap) radius.resize(cap) data.resize(cap)
func add(p: Vector2, v: Vector2, r: float, d: float) -> int: if count >= capacity: return -1 var i := count pos[i] = p vel[i] = v radius[i] = r data[i] = d count += 1 return i
func remove_at(i: int) -> void: var last := count - 1 if i != last: pos[i] = pos[last] vel[i] = vel[last] radius[i] = radius[last] data[i] = data[last] count -= 1- Step 4: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_entity_pool.gd -gexit
Expected: PASS — 4 tests.
- Step 5: Commit
git add sim/entity_pool.gd tests/test_entity_pool.gdgit commit -m "feat(sim): data-oriented EntityPool with swap-remove"Task 4: SpatialHash (uniform grid neighbour queries)
Section titled “Task 4: SpatialHash (uniform grid neighbour queries)”Files:
- Create:
sim/spatial_hash.gd - Test:
tests/test_spatial_hash.gd
Interfaces:
-
Produces
SpatialHash.new(cell_size: float):rebuild(pool: EntityPool) -> void— clears and inserts indices[0, pool.count)keyed bypool.pos.query_circle(center: Vector2, r: float, pool: EntityPool) -> PackedInt32Array— returns indices whose position is withinrofcenter(distance-checked, not just cell-overlap).
-
Step 1: Write the failing test
Create tests/test_spatial_hash.gd:
extends GutTest
func _pool_with(points: Array) -> EntityPool: var pool := EntityPool.new(points.size()) for p in points: pool.add(p, Vector2.ZERO, 1.0, 0.0) return pool
func test_query_finds_near_points_excludes_far() -> void: var pool := _pool_with([Vector2(0, 0), Vector2(10, 0), Vector2(500, 500)]) var sh := SpatialHash.new(64.0) sh.rebuild(pool) var hits := sh.query_circle(Vector2(0, 0), 50.0, pool) assert_eq(hits.size(), 2, "two points within 50px") assert_true(hits.has(0) and hits.has(1), "indices 0 and 1 are near") assert_false(hits.has(2), "far point excluded")
func test_query_respects_exact_radius() -> void: var pool := _pool_with([Vector2(0, 0), Vector2(100, 0)]) var sh := SpatialHash.new(64.0) sh.rebuild(pool) assert_eq(sh.query_circle(Vector2(0, 0), 99.0, pool).size(), 1, "100px away, r=99 excludes") assert_eq(sh.query_circle(Vector2(0, 0), 101.0, pool).size(), 2, "r=101 includes")
func test_rebuild_clears_previous() -> void: var sh := SpatialHash.new(64.0) sh.rebuild(_pool_with([Vector2(0, 0)])) sh.rebuild(_pool_with([Vector2(1000, 1000)])) assert_eq(sh.query_circle(Vector2(0, 0), 50.0, _pool_with([Vector2(1000, 1000)])).size(), 0)- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_spatial_hash.gd -gexit
Expected: FAIL — SpatialHash not found.
- Step 3: Implement SpatialHash
Create sim/spatial_hash.gd:
class_name SpatialHashextends RefCounted
var _cell_size: floatvar _cells: Dictionary = {} # Vector2i -> PackedInt32Array
func _init(cell_size: float) -> void: _cell_size = cell_size
func _key(p: Vector2) -> Vector2i: return Vector2i(floori(p.x / _cell_size), floori(p.y / _cell_size))
func rebuild(pool: EntityPool) -> void: _cells.clear() for i in range(pool.count): var k := _key(pool.pos[i]) if not _cells.has(k): _cells[k] = PackedInt32Array() _cells[k].append(i)
func query_circle(center: Vector2, r: float, pool: EntityPool) -> PackedInt32Array: var result := PackedInt32Array() var min_c := Vector2i(floori((center.x - r) / _cell_size), floori((center.y - r) / _cell_size)) var max_c := Vector2i(floori((center.x + r) / _cell_size), floori((center.y + r) / _cell_size)) var r2 := r * r for cx in range(min_c.x, max_c.x + 1): for cy in range(min_c.y, max_c.y + 1): var k := Vector2i(cx, cy) if not _cells.has(k): continue for idx in _cells[k]: if center.distance_squared_to(pool.pos[idx]) <= r2: result.append(idx) return result- Step 4: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_spatial_hash.gd -gexit
Expected: PASS — 3 tests.
- Step 5: Commit
git add sim/spatial_hash.gd tests/test_spatial_hash.gdgit commit -m "feat(sim): uniform-grid spatial hash neighbour queries"Task 5: PlayerState (movement + bounds + stats)
Section titled “Task 5: PlayerState (movement + bounds + stats)”Files:
- Create:
sim/player_state.gd - Test:
tests/test_player_state.gd
Interfaces:
-
Produces
PlayerState.new()with fields:pos: Vector2 = Vector2.ZERO,hp: float = 100.0,max_hp: float = 100.0,speed: float = 260.0,pickup_radius: float = 90.0,radius: float = 16.0,level: int = 1,xp: float = 0.0,xp_to_next: float = 10.0,damage_mult: float = 1.0,fire_rate_mult: float = 1.0.integrate(input: InputState, dt: float) -> void— moves bymove_dir * speed * dt, clamps to±Sim_Const.ARENA_HALF.
-
Step 1: Create InputState (dependency)
Create sim/input_state.gd:
class_name InputStateextends RefCounted
# move_dir is expected normalized (length 0 or 1) by the sim.var move_dir: Vector2 = Vector2.ZERO
func _init(dir: Vector2 = Vector2.ZERO) -> void: move_dir = dir- Step 2: Write the failing test
Create tests/test_player_state.gd:
extends GutTest
func test_moves_in_input_direction() -> void: var p := PlayerState.new() p.speed = 100.0 p.integrate(InputState.new(Vector2(1, 0)), 0.5) assert_almost_eq(p.pos.x, 50.0, 0.001) assert_almost_eq(p.pos.y, 0.0, 0.001)
func test_no_input_no_move() -> void: var p := PlayerState.new() p.integrate(InputState.new(Vector2.ZERO), 1.0) assert_eq(p.pos, Vector2.ZERO)
func test_clamped_to_arena_bounds() -> void: var p := PlayerState.new() p.pos = Vector2(Sim_Const.ARENA_HALF - 1.0, 0) p.speed = 10000.0 p.integrate(InputState.new(Vector2(1, 0)), 1.0) assert_almost_eq(p.pos.x, Sim_Const.ARENA_HALF, 0.001, "cannot leave arena")- Step 3: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_player_state.gd -gexit
Expected: FAIL — PlayerState not found.
- Step 4: Implement PlayerState
Create sim/player_state.gd:
class_name PlayerStateextends RefCounted
var pos: Vector2 = Vector2.ZEROvar hp: float = 100.0var max_hp: float = 100.0var speed: float = 260.0var pickup_radius: float = 90.0var radius: float = 16.0var level: int = 1var xp: float = 0.0var xp_to_next: float = 10.0var damage_mult: float = 1.0var fire_rate_mult: float = 1.0
func integrate(input: InputState, dt: float) -> void: pos += input.move_dir * speed * dt pos.x = clampf(pos.x, -Sim_Const.ARENA_HALF, Sim_Const.ARENA_HALF) pos.y = clampf(pos.y, -Sim_Const.ARENA_HALF, Sim_Const.ARENA_HALF)- Step 5: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_player_state.gd -gexit
Expected: PASS — 3 tests.
- Step 6: Commit
git add sim/input_state.gd sim/player_state.gd tests/test_player_state.gdgit commit -m "feat(sim): InputState + PlayerState movement with arena bounds"Task 6: Sim core (owns state, constant-dt tick, player + run timer)
Section titled “Task 6: Sim core (owns state, constant-dt tick, player + run timer)”Files:
- Create:
sim/sim.gd - Test:
tests/test_sim_core.gd
Interfaces:
-
Produces
Sim.new(seed_value: int)with public fields:rng: SeededRng,player: PlayerState,enemies: EntityPool,projectiles: EntityPool,gems: EntityPool,hash: SpatialHash,run_time: float = 0.0,kills: int = 0,game_over: bool = false.- Constants on Sim:
ENEMY_CAP := 6000,PROJ_CAP := 4000,GEM_CAP := 4000,HASH_CELL := 64.0. tick(input: InputState) -> void— advances exactly one fixed step usingSim_Const.DT: integrates the player, advancesrun_time. (Enemy/weapon/collision behaviour is added in later tasks; method body grows.)
- Constants on Sim:
-
Later tasks (7–12) extend
tick()and add helper methods onSim. Their tests instantiateSimand drive it throughtick(). -
Step 1: Write the failing test
Create tests/test_sim_core.gd:
extends GutTest
func test_tick_advances_run_time_by_dt() -> void: var sim := Sim.new(1) sim.tick(InputState.new(Vector2.ZERO)) assert_almost_eq(sim.run_time, Sim_Const.DT, 0.00001) sim.tick(InputState.new(Vector2.ZERO)) assert_almost_eq(sim.run_time, Sim_Const.DT * 2.0, 0.00001)
func test_tick_uses_constant_dt_not_wall_time() -> void: # Moving right for 60 ticks at speed 260 => 260px in 1 simulated second. var sim := Sim.new(1) for i in range(60): sim.tick(InputState.new(Vector2(1, 0))) assert_almost_eq(sim.player.pos.x, 260.0, 1.0)
func test_pools_allocated() -> void: var sim := Sim.new(1) assert_eq(sim.enemies.count, 0) assert_eq(sim.projectiles.count, 0) assert_eq(sim.gems.count, 0) assert_false(sim.game_over)- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_sim_core.gd -gexit
Expected: FAIL — Sim not found.
- Step 3: Implement Sim core
Create sim/sim.gd:
class_name Simextends RefCounted
const ENEMY_CAP: int = 6000const PROJ_CAP: int = 4000const GEM_CAP: int = 4000const HASH_CELL: float = 64.0
var rng: SeededRngvar player: PlayerStatevar enemies: EntityPoolvar projectiles: EntityPoolvar gems: EntityPoolvar hash: SpatialHashvar run_time: float = 0.0var kills: int = 0var game_over: bool = false
func _init(seed_value: int) -> void: rng = SeededRng.new(seed_value) player = PlayerState.new() enemies = EntityPool.new(ENEMY_CAP) projectiles = EntityPool.new(PROJ_CAP) gems = EntityPool.new(GEM_CAP) hash = SpatialHash.new(HASH_CELL)
func tick(input: InputState) -> void: if game_over: return var dt := Sim_Const.DT player.integrate(input, dt) run_time += dt- Step 4: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_sim_core.gd -gexit
Expected: PASS — 3 tests.
- Step 5: Commit
git add sim/sim.gd tests/test_sim_core.gdgit commit -m "feat(sim): Sim core with constant-dt tick, pools, run timer"Task 7: SpawnDirector + enemy spawning
Section titled “Task 7: SpawnDirector + enemy spawning”Files:
- Create:
sim/spawn_director.gd - Modify:
sim/sim.gd(call the director intick, add_spawn_enemy) - Test:
tests/test_spawn_director.gd
Interfaces:
-
Produces
SpawnDirector.new():spawn_count_for(run_time: float, dt: float, accum: float) -> Dictionary— returns{ "to_spawn": int, "accum": float }. Spawn rate rises with time:rate = 2.0 + run_time * 0.5enemies/sec; carries fractional remainder inaccum.
-
Modifies
Sim: addspawner: SpawnDirector,_spawn_accum: float = 0.0, and intickspawn that many enemies at a random point on a circle of radiusSPAWN_RING := 1100.0around the player. Enemy entry:enemies.add(spawn_pos, Vector2.ZERO, ENEMY_RADIUS, ENEMY_HP)withENEMY_RADIUS := 14.0,ENEMY_HP := 3.0. -
Step 1: Write the failing test
Create tests/test_spawn_director.gd:
extends GutTest
func test_accumulates_fractional_spawns() -> void: var d := SpawnDirector.new() # rate at t=0 is 2/sec; dt=1/60 => 0.0333 per tick, no spawn yet var r := d.spawn_count_for(0.0, 1.0 / 60.0, 0.0) assert_eq(r["to_spawn"], 0) assert_gt(r["accum"], 0.0)
func test_spawns_when_accum_exceeds_one() -> void: var d := SpawnDirector.new() var r := d.spawn_count_for(0.0, 1.0, 0.0) # 1 full second at rate 2 => 2 spawns assert_eq(r["to_spawn"], 2) assert_almost_eq(r["accum"], 0.0, 0.0001)
func test_rate_increases_over_time() -> void: var d := SpawnDirector.new() var early := d.spawn_count_for(0.0, 1.0, 0.0)["to_spawn"] var late := d.spawn_count_for(60.0, 1.0, 0.0)["to_spawn"] assert_gt(late, early, "spawn rate should rise with run_time")
func test_sim_tick_spawns_enemies_over_time() -> void: var sim := Sim.new(5) for i in range(120): # ~2 simulated seconds sim.tick(InputState.new(Vector2.ZERO)) assert_gt(sim.enemies.count, 0, "enemies should have spawned")- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_spawn_director.gd -gexit
Expected: FAIL — SpawnDirector not found.
- Step 3: Implement SpawnDirector
Create sim/spawn_director.gd:
class_name SpawnDirectorextends RefCounted
func rate_at(run_time: float) -> float: return 2.0 + run_time * 0.5
func spawn_count_for(run_time: float, dt: float, accum: float) -> Dictionary: accum += rate_at(run_time) * dt var n := int(floor(accum)) accum -= float(n) return { "to_spawn": n, "accum": accum }- Step 4: Wire into Sim
Modify sim/sim.gd. Add these constants near the others:
const SPAWN_RING: float = 1100.0const ENEMY_RADIUS: float = 14.0const ENEMY_HP: float = 3.0Add fields near the other vars:
var spawner: SpawnDirectorvar _spawn_accum: float = 0.0In _init, after hash = ..., add:
spawner = SpawnDirector.new()In tick, after run_time += dt, add:
_spawn_enemies(dt)
func _spawn_enemies(dt: float) -> void: var r := spawner.spawn_count_for(run_time, dt, _spawn_accum) _spawn_accum = r["accum"] for i in range(r["to_spawn"]): var spawn_pos := player.pos + rng.rand_unit_dir() * SPAWN_RING enemies.add(spawn_pos, Vector2.ZERO, ENEMY_RADIUS, ENEMY_HP)- Step 5: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_spawn_director.gd -gexit
Expected: PASS — 4 tests.
- Step 6: Commit
git add sim/spawn_director.gd sim/sim.gd tests/test_spawn_director.gdgit commit -m "feat(sim): time-escalating spawn director + enemy spawning"Task 8: Enemy chase movement
Section titled “Task 8: Enemy chase movement”Files:
- Modify:
sim/sim.gd(add_move_enemies, call intick) - Test:
tests/test_enemy_chase.gd
Interfaces:
-
Modifies
Sim: addENEMY_SPEED := 70.0;_move_enemies(dt)moves every active enemy towardplayer.posatENEMY_SPEED. Called intickafter spawning. -
Step 1: Write the failing test
Create tests/test_enemy_chase.gd:
extends GutTest
func test_enemy_moves_toward_player() -> void: var sim := Sim.new(1) sim.player.pos = Vector2.ZERO var idx := sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 3.0) var before := sim.enemies.pos[idx].distance_to(sim.player.pos) sim.tick(InputState.new(Vector2.ZERO)) var after := sim.enemies.pos[0].distance_to(sim.player.pos) assert_lt(after, before, "enemy should get closer to the player")- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_enemy_chase.gd -gexit
Expected: FAIL — enemy does not move (no chase logic yet).
- Step 3: Implement chase
Modify sim/sim.gd. Add constant:
const ENEMY_SPEED: float = 70.0In tick, after _spawn_enemies(dt), add the call and method:
_move_enemies(dt)
func _move_enemies(dt: float) -> void: for i in range(enemies.count): var dir := (player.pos - enemies.pos[i]) if dir.length() > 0.001: enemies.pos[i] += dir.normalized() * ENEMY_SPEED * dt- Step 4: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_enemy_chase.gd -gexit
Expected: PASS — 1 test.
- Step 5: Commit
git add sim/sim.gd tests/test_enemy_chase.gdgit commit -m "feat(sim): enemies chase the player"Task 9: WeaponPulse (auto-fire + auto-target nearest)
Section titled “Task 9: WeaponPulse (auto-fire + auto-target nearest)”Files:
- Create:
sim/weapon_pulse.gd - Modify:
sim/sim.gd(own aWeaponPulse, call intick, add_move_projectiles) - Test:
tests/test_weapon_pulse.gd
Interfaces:
-
Produces
WeaponPulse.new():cooldown: float = 0.6(seconds between shots, divided byplayer.fire_rate_mult),_timer: float = 0.0,proj_speed: float = 520.0,proj_radius: float = 6.0,base_damage: float = 1.0,proj_lifetime: float = 1.4.update(sim: Sim, dt: float) -> void— decrements timer; when ready and an enemy exists, fires one projectile toward the nearest enemy and resets the timer. Projectiledatafield stores remaining lifetime; damage is read frombase_damage * player.damage_multat fire time and encoded by spawning the projectile withdata = lifetimewhile damage is applied via a parallel rule (see note).nearest_enemy_index(sim: Sim) -> int— returns index of closest active enemy to the player, or -1.
-
Decision to keep the pool generic: projectile
dataholds remaining lifetime. Projectile damage for Milestone 1 is the constantWeaponPulse.base_damage * player.damage_multevaluated in the collision step viasim.proj_damage(a field the weapon sets when it fires). Storesim.proj_damage: floatupdated on each shot. -
Modifies
Sim: addweapon: WeaponPulse,proj_damage: float = 1.0,PROJ_OFFSCREEN_CULLhandled by lifetime._move_projectiles(dt)advances projectiles and decrementsdata(lifetime), swap-removing expired ones (iterate backwards). -
Step 1: Write the failing test
Create tests/test_weapon_pulse.gd:
extends GutTest
func test_fires_after_cooldown_toward_enemy() -> void: var sim := Sim.new(1) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(200, 0), Vector2.ZERO, 14.0, 3.0) # advance enough simulated time to exceed the 0.6s cooldown for i in range(50): sim.tick(InputState.new(Vector2.ZERO)) assert_gt(sim.projectiles.count, 0, "weapon should have fired") # first projectile should travel roughly +x toward the enemy assert_gt(sim.projectiles.vel[0].x, 0.0)
func test_no_fire_without_enemies() -> void: var sim := Sim.new(1) for i in range(50): sim.tick(InputState.new(Vector2.ZERO)) # With no enemies present at all the weapon stays idle. # (Spawning is on, so assert via a fresh weapon in isolation instead.) var w := WeaponPulse.new() var bare := Sim.new(1) bare.enemies.count = 0 w.update(bare, 1.0) assert_eq(bare.projectiles.count, 0)
func test_nearest_enemy_index() -> void: var sim := Sim.new(1) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(500, 0), Vector2.ZERO, 14.0, 3.0) # idx 0 far sim.enemies.add(Vector2(50, 0), Vector2.ZERO, 14.0, 3.0) # idx 1 near assert_eq(sim.weapon.nearest_enemy_index(sim), 1)- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_weapon_pulse.gd -gexit
Expected: FAIL — WeaponPulse not found.
- Step 3: Implement WeaponPulse
Create sim/weapon_pulse.gd:
class_name WeaponPulseextends RefCounted
var cooldown: float = 0.6var proj_speed: float = 520.0var proj_radius: float = 6.0var base_damage: float = 1.0var proj_lifetime: float = 1.4var _timer: float = 0.0
func nearest_enemy_index(sim: Sim) -> int: var best := -1 var best_d2 := INF for i in range(sim.enemies.count): var d2 := sim.player.pos.distance_squared_to(sim.enemies.pos[i]) if d2 < best_d2: best_d2 = d2 best = i return best
func update(sim: Sim, dt: float) -> void: _timer -= dt if _timer > 0.0: return var target := nearest_enemy_index(sim) if target == -1: return var dir := (sim.enemies.pos[target] - sim.player.pos).normalized() sim.proj_damage = base_damage * sim.player.damage_mult sim.projectiles.add(sim.player.pos, dir * proj_speed, proj_radius, proj_lifetime) _timer = cooldown / maxf(sim.player.fire_rate_mult, 0.01)- Step 4: Wire into Sim
Modify sim/sim.gd. Add field:
var weapon: WeaponPulsevar proj_damage: float = 1.0In _init, after spawner = ...:
weapon = WeaponPulse.new()In tick, after _move_enemies(dt):
weapon.update(self, dt) _move_projectiles(dt)
func _move_projectiles(dt: float) -> void: var i := projectiles.count - 1 while i >= 0: projectiles.pos[i] += projectiles.vel[i] * dt projectiles.data[i] -= dt # remaining lifetime if projectiles.data[i] <= 0.0: projectiles.remove_at(i) i -= 1- Step 5: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_weapon_pulse.gd -gexit
Expected: PASS — 3 tests.
- Step 6: Commit
git add sim/weapon_pulse.gd sim/sim.gd tests/test_weapon_pulse.gdgit commit -m "feat(sim): WeaponPulse auto-fire + auto-target + projectile motion"Task 10: Collision & damage (projectile↔enemy, death, gem drop)
Section titled “Task 10: Collision & damage (projectile↔enemy, death, gem drop)”Files:
- Modify:
sim/sim.gd(add_resolve_collisions, call intick) - Test:
tests/test_collision_damage.gd
Interfaces:
-
Modifies
Sim: addGEM_RADIUS := 8.0,GEM_XP := 1.0._resolve_collisions():- Rebuilds
hashfromenemies. - For each projectile (backwards),
query_circle(proj_pos, proj_radius + ENEMY_RADIUS, enemies); on first hit, subtractproj_damagefrom that enemy’sdata(HP), remove the projectile. If enemy HP<= 0: spawn a gem at the enemy pos (gems.add(pos, ZERO, GEM_RADIUS, GEM_XP)),kills += 1, swap-remove the enemy. - Called in
tickafter_move_projectiles.
- Rebuilds
-
Step 1: Write the failing test
Create tests/test_collision_damage.gd:
extends GutTest
func test_projectile_damages_and_kills_enemy_dropping_gem() -> void: var sim := Sim.new(1) sim.player.pos = Vector2.ZERO # one enemy with 1 HP right next to a projectile carrying 1 damage sim.enemies.add(Vector2(10, 0), Vector2.ZERO, 14.0, 1.0) sim.proj_damage = 1.0 sim.projectiles.add(Vector2(10, 0), Vector2.ZERO, 6.0, 1.0) sim._resolve_collisions() assert_eq(sim.enemies.count, 0, "enemy killed") assert_eq(sim.projectiles.count, 0, "projectile consumed") assert_eq(sim.gems.count, 1, "gem dropped") assert_eq(sim.kills, 1)
func test_projectile_damages_without_killing() -> void: var sim := Sim.new(1) sim.enemies.add(Vector2(10, 0), Vector2.ZERO, 14.0, 3.0) # 3 HP sim.proj_damage = 1.0 sim.projectiles.add(Vector2(10, 0), Vector2.ZERO, 6.0, 1.0) sim._resolve_collisions() assert_eq(sim.enemies.count, 1, "enemy survives") assert_almost_eq(sim.enemies.data[0], 2.0, 0.001, "HP reduced by damage") assert_eq(sim.projectiles.count, 0, "projectile consumed")
func test_miss_leaves_everything() -> void: var sim := Sim.new(1) sim.enemies.add(Vector2(1000, 0), Vector2.ZERO, 14.0, 3.0) sim.projectiles.add(Vector2(0, 0), Vector2.ZERO, 6.0, 1.0) sim._resolve_collisions() assert_eq(sim.enemies.count, 1) assert_eq(sim.projectiles.count, 1)- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_collision_damage.gd -gexit
Expected: FAIL — _resolve_collisions not found.
- Step 3: Implement collisions
Modify sim/sim.gd. Add constants:
const GEM_RADIUS: float = 8.0const GEM_XP: float = 1.0In tick, after _move_projectiles(dt):
_resolve_collisions()
func _resolve_collisions() -> void: hash.rebuild(enemies) var pi := projectiles.count - 1 while pi >= 0: var ppos := projectiles.pos[pi] var hits := hash.query_circle(ppos, projectiles.radius[pi] + ENEMY_RADIUS, enemies) if hits.size() > 0: var ei: int = hits[0] enemies.data[ei] -= proj_damage projectiles.remove_at(pi) if enemies.data[ei] <= 0.0: gems.add(enemies.pos[ei], Vector2.ZERO, GEM_RADIUS, GEM_XP) kills += 1 enemies.remove_at(ei) pi -= 1- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_collision_damage.gd -gexit
Expected: PASS — 3 tests.
- Step 5: Commit
git add sim/sim.gd tests/test_collision_damage.gdgit commit -m "feat(sim): projectile-enemy collision, damage, death, gem drop"Task 11: XP pickup, level-up, contact death
Section titled “Task 11: XP pickup, level-up, contact death”Files:
- Modify:
sim/sim.gd(add_collect_gems,_check_player_hit,pending_levelups) - Test:
tests/test_xp_levelup.gd
Interfaces:
-
Modifies
Sim: addpending_levelups: int = 0,CONTACT_DPS := 12.0._collect_gems()— gems withinplayer.pickup_radiusare collected:player.xp += gem.data, gem removed. Whileplayer.xp >= player.xp_to_next: subtract,player.level += 1,player.xp_to_next *= 1.35,pending_levelups += 1._check_player_hit(dt)— any enemy withinplayer.radius + ENEMY_RADIUSdrainsCONTACT_DPS * dt * <count>HP; ifplayer.hp <= 0, setgame_over = true.- Both called in
tick(gems then player-hit) after_resolve_collisions.
-
Step 1: Write the failing test
Create tests/test_xp_levelup.gd:
extends GutTest
func test_collect_gem_adds_xp() -> void: var sim := Sim.new(1) sim.player.pos = Vector2.ZERO sim.player.pickup_radius = 100.0 sim.gems.add(Vector2(10, 0), Vector2.ZERO, 8.0, 5.0) sim._collect_gems() assert_eq(sim.gems.count, 0, "gem collected") assert_almost_eq(sim.player.xp, 5.0, 0.001)
func test_far_gem_not_collected() -> void: var sim := Sim.new(1) sim.player.pickup_radius = 50.0 sim.gems.add(Vector2(500, 0), Vector2.ZERO, 8.0, 5.0) sim._collect_gems() assert_eq(sim.gems.count, 1)
func test_level_up_triggers_pending() -> void: var sim := Sim.new(1) sim.player.pickup_radius = 100.0 sim.player.xp_to_next = 10.0 sim.gems.add(Vector2.ZERO, Vector2.ZERO, 8.0, 12.0) # exceeds threshold sim._collect_gems() assert_eq(sim.player.level, 2) assert_eq(sim.pending_levelups, 1) assert_gt(sim.player.xp_to_next, 10.0, "threshold grows")
func test_contact_kills_player() -> void: var sim := Sim.new(1) sim.player.pos = Vector2.ZERO sim.player.hp = 0.001 sim.enemies.add(Vector2.ZERO, Vector2.ZERO, 14.0, 3.0) # overlapping sim._check_player_hit(Sim_Const.DT) assert_true(sim.game_over, "overlapping enemy should kill near-dead player")- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_xp_levelup.gd -gexit
Expected: FAIL — methods not found.
- Step 3: Implement pickup, leveling, contact damage
Modify sim/sim.gd. Add field + constant:
var pending_levelups: int = 0const CONTACT_DPS: float = 12.0In tick, after _resolve_collisions():
_collect_gems() _check_player_hit(dt)
func _collect_gems() -> void: var pr2 := player.pickup_radius * player.pickup_radius var i := gems.count - 1 while i >= 0: if player.pos.distance_squared_to(gems.pos[i]) <= pr2: player.xp += gems.data[i] gems.remove_at(i) i -= 1 while player.xp >= player.xp_to_next: player.xp -= player.xp_to_next player.level += 1 player.xp_to_next *= 1.35 pending_levelups += 1
func _check_player_hit(dt: float) -> void: var reach := player.radius + ENEMY_RADIUS var reach2 := reach * reach var touching := 0 for i in range(enemies.count): if player.pos.distance_squared_to(enemies.pos[i]) <= reach2: touching += 1 if touching > 0: player.hp -= CONTACT_DPS * dt * float(touching) if player.hp <= 0.0: player.hp = 0.0 game_over = true- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_xp_levelup.gd -gexit
Expected: PASS — 4 tests.
- Step 5: Commit
git add sim/sim.gd tests/test_xp_levelup.gdgit commit -m "feat(sim): XP pickup, level-up thresholds, contact death"Task 12: Stat upgrades + determinism guard
Section titled “Task 12: Stat upgrades + determinism guard”Files:
- Create:
sim/upgrades.gd - Modify:
sim/sim.gd(addapply_upgrade, consumepending_levelups) - Test:
tests/test_upgrades.gd - Test:
tests/test_determinism.gd
Interfaces:
-
Produces
Upgradeswith a static catalog and apply logic:Upgrades.ALL: Array[Dictionary]— each{ "id": String, "name": String, "desc": String }. Milestone-1 set:damage(+25% damage),fire_rate(+20% fire rate),move_speed(+12% speed),pickup(+30% pickup radius),max_hp(+25 max HP, heal to full-of-bonus),regenplaceholder excluded.Upgrades.apply(id: String, player: PlayerState) -> void.Upgrades.roll_choices(rng: SeededRng, n: int) -> Array[String]— returnsndistinct random upgrade ids.
-
Modifies
Sim:apply_upgrade(id: String) -> voidcallsUpgrades.applyand decrementspending_levelups(floored at 0). -
Step 1: Write the failing test
Create tests/test_upgrades.gd:
extends GutTest
func test_damage_upgrade_raises_mult() -> void: var p := PlayerState.new() Upgrades.apply("damage", p) assert_almost_eq(p.damage_mult, 1.25, 0.001)
func test_max_hp_upgrade_raises_cap_and_heals_bonus() -> void: var p := PlayerState.new() var before := p.max_hp Upgrades.apply("max_hp", p) assert_almost_eq(p.max_hp, before + 25.0, 0.001)
func test_roll_choices_distinct() -> void: var rng := SeededRng.new(3) var choices := Upgrades.roll_choices(rng, 3) assert_eq(choices.size(), 3) assert_eq(choices.size(), _unique(choices).size(), "choices must be distinct")
func test_sim_apply_upgrade_consumes_pending() -> void: var sim := Sim.new(1) sim.pending_levelups = 2 sim.apply_upgrade("move_speed") assert_eq(sim.pending_levelups, 1) assert_gt(sim.player.speed, 260.0)
func _unique(a: Array) -> Array: var seen := {} for x in a: seen[x] = true return seen.keys()- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_upgrades.gd -gexit
Expected: FAIL — Upgrades not found.
- Step 3: Implement Upgrades
Create sim/upgrades.gd:
class_name Upgradesextends RefCounted
const ALL: Array[Dictionary] = [ { "id": "damage", "name": "Sharpened Pulse", "desc": "+25% damage" }, { "id": "fire_rate", "name": "Overclock", "desc": "+20% fire rate" }, { "id": "move_speed", "name": "Thrusters", "desc": "+12% move speed" }, { "id": "pickup", "name": "Magnet Field", "desc": "+30% pickup radius" }, { "id": "max_hp", "name": "Plating", "desc": "+25 max HP" },]
static func apply(id: String, player: PlayerState) -> void: match id: "damage": player.damage_mult *= 1.25 "fire_rate": player.fire_rate_mult *= 1.20 "move_speed": player.speed *= 1.12 "pickup": player.pickup_radius *= 1.30 "max_hp": player.max_hp += 25.0 player.hp += 25.0
static func roll_choices(rng: SeededRng, n: int) -> Array[String]: var ids: Array[String] = [] for entry in ALL: ids.append(entry["id"]) # Fisher-Yates partial shuffle using the seeded rng for i in range(ids.size()): var j := rng.randi_range(i, ids.size() - 1) var tmp := ids[i] ids[i] = ids[j] ids[j] = tmp return ids.slice(0, mini(n, ids.size()))- Step 4: Wire into Sim
Modify sim/sim.gd, add method:
func apply_upgrade(id: String) -> void: Upgrades.apply(id, player) pending_levelups = maxi(pending_levelups - 1, 0)- Step 5: Run upgrade tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_upgrades.gd -gexit
Expected: PASS — 4 tests.
- Step 6: Add
snapshot_string()to Sim
Mirrors the proven moba-bakeoff/slice-godot convention: a compact per-tick state string
used for trace comparison (and reusable as Phase-3 desync detection). Modify sim/sim.gd,
add:
func snapshot_string() -> String: return "t=%d p=(%.3f,%.3f) hp=%.3f e=%d pr=%d g=%d k=%d xp=%.3f lv=%d" % [ int(round(run_time / Sim_Const.DT)), player.pos.x, player.pos.y, player.hp, enemies.count, projectiles.count, gems.count, kills, player.xp, player.level, ]- Step 7: Write the determinism guard test (per-tick trace)
Create tests/test_determinism.gd. We compare the full per-tick trace, not just the
final state, so a mismatch pinpoints the exact tick of divergence (the
moba-bakeoff/slice-godot/tests/determinism_check.gd technique):
extends GutTest
func _trace(seed_value: int, frames: int) -> Array: var sim := Sim.new(seed_value) var trace: Array = [] for i in range(frames): # A fixed, reproducible input pattern (no wall-clock, no Math.random). var dir := Vector2(cos(float(i) * 0.05), sin(float(i) * 0.03)) if dir.length() > 0.0: dir = dir.normalized() sim.tick(InputState.new(dir)) trace.append(sim.snapshot_string()) return trace
func test_same_seed_identical_trace() -> void: var a := _trace(1234, 600) # 10 simulated seconds var b := _trace(1234, 600) assert_eq(a.size(), b.size()) for i in range(a.size()): assert_eq(a[i], b[i], "divergence at tick %d:\n A=%s\n B=%s" % [i, a[i], b[i]])
func test_different_seed_diverges() -> void: var a := _trace(1, 600) var b := _trace(2, 600) assert_true(a != b, "different seeds should diverge somewhere")- Step 8: Run the full sim suite
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit; echo "exit=$?"
Expected: ALL tests pass, exit=0.
- Step 9: Commit
git add sim/upgrades.gd sim/sim.gd tests/test_upgrades.gd tests/test_determinism.gdgit commit -m "feat(sim): stat upgrades + snapshot_string + per-tick determinism guard"Task 13: SwarmRenderer + arena background (visual swarm)
Section titled “Task 13: SwarmRenderer + arena background (visual swarm)”Files:
- Create:
render/swarm_renderer.gd - Create:
render/arena_background.gd - Test:
tests/test_swarm_renderer.gd
Interfaces:
-
Produces
SwarmRenderer(extendsMultiMeshInstance2D):configure(mesh_radius: float, color: Color) -> void— builds aQuadMeshof sizemesh_radius*2, aMultiMeshwithtransform_format = TRANSFORM_2D,use_colors = true.sync(pool: EntityPool, color: Color) -> void— setsmultimesh.instance_count = pool.countand writes each instance’stransform_2d(translation =pool.pos[i]) andcolor.
-
This is the first Node-based file; it MAY use Node/MultiMesh APIs. It reads
EntityPoolbut never mutates the sim. -
Step 1: Write the failing test (headless instance count)
Create tests/test_swarm_renderer.gd:
extends GutTest
func test_sync_sets_instance_count_and_transform() -> void: var r := SwarmRenderer.new() r.configure(14.0, Color.RED) var pool := EntityPool.new(4) pool.add(Vector2(100, 50), Vector2.ZERO, 14.0, 0.0) pool.add(Vector2(-30, 0), Vector2.ZERO, 14.0, 0.0) r.sync(pool, Color.RED) assert_eq(r.multimesh.instance_count, 2, "instance count matches active entities") var t: Transform2D = r.multimesh.get_instance_transform_2d(0) assert_almost_eq(t.origin.x, 100.0, 0.001) assert_almost_eq(t.origin.y, 50.0, 0.001) r.free()- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_swarm_renderer.gd -gexit
Expected: FAIL — SwarmRenderer not found.
- Step 3: Implement SwarmRenderer
Create render/swarm_renderer.gd:
class_name SwarmRendererextends MultiMeshInstance2D
func configure(mesh_radius: float, color: Color) -> void: var quad := QuadMesh.new() quad.size = Vector2(mesh_radius * 2.0, mesh_radius * 2.0) var mm := MultiMesh.new() mm.transform_format = MultiMesh.TRANSFORM_2D mm.use_colors = true mm.mesh = quad mm.instance_count = 0 multimesh = mm modulate = color
func sync(pool: EntityPool, color: Color) -> void: multimesh.instance_count = pool.count for i in range(pool.count): multimesh.set_instance_transform_2d(i, Transform2D(0.0, pool.pos[i])) multimesh.set_instance_color(i, color)- Step 4: Implement arena background
Create render/arena_background.gd:
class_name ArenaBackgroundextends Node2D
const STEP: float = 100.0const LINE_COLOR: Color = Color(0.15, 0.55, 0.9, 0.25)
func _draw() -> void: var h := Sim_Const.ARENA_HALF var x := -h while x <= h: draw_line(Vector2(x, -h), Vector2(x, h), LINE_COLOR, 1.0) x += STEP var y := -h while y <= h: draw_line(Vector2(-h, y), Vector2(h, y), LINE_COLOR, 1.0) y += STEP draw_rect(Rect2(-h, -h, h * 2.0, h * 2.0), Color(0.4, 0.8, 1.0, 0.6), false, 3.0)- Step 5: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_swarm_renderer.gd -gexit
Expected: PASS — 1 test.
- Step 6: Commit
git add render/swarm_renderer.gd render/arena_background.gd tests/test_swarm_renderer.gdgit commit -m "feat(render): MultiMesh swarm renderer + neon arena grid"Task 14: InputRouter (keyboard/gamepad/touch → InputState)
Section titled “Task 14: InputRouter (keyboard/gamepad/touch → InputState)”Files:
- Create:
input/input_router.gd - Modify:
project.godot(add input actions) - Test:
tests/test_input_router.gd
Interfaces:
-
Produces
InputRouter(extendsNode):poll() -> InputState— reads movement: keyboard/gamepad via themove_*actions OR an injected virtual-joystick vector (set_touch_vector(v: Vector2)), returns anInputStatewith a normalizedmove_dir.set_touch_vector(v: Vector2) -> void— for the on-screen joystick (wired in a later milestone; field exists now).
-
Pure-logic helper
InputRouter.normalize_dir(raw: Vector2) -> Vector2(static) is what the test targets (no liveInputsingleton needed in headless tests). -
Step 1: Add input actions to project.godot
Append to project.godot:
[input]move_up={"deadzone":0.2,"events":[{"type":"InputEventKey","keycode":87}]}move_down={"deadzone":0.2,"events":[{"type":"InputEventKey","keycode":83}]}move_left={"deadzone":0.2,"events":[{"type":"InputEventKey","keycode":65}]}move_right={"deadzone":0.2,"events":[{"type":"InputEventKey","keycode":68}]}(Keycodes: W=87, S=83, A=65, D=68.)
- Step 2: Write the failing test
Create tests/test_input_router.gd:
extends GutTest
func test_normalize_zero_stays_zero() -> void: assert_eq(InputRouter.normalize_dir(Vector2.ZERO), Vector2.ZERO)
func test_normalize_unit_length() -> void: var d := InputRouter.normalize_dir(Vector2(3, 4)) assert_almost_eq(d.length(), 1.0, 0.0001)
func test_touch_vector_used_when_set() -> void: var r := InputRouter.new() r.set_touch_vector(Vector2(0, 1)) var s := r.poll() assert_almost_eq(s.move_dir.y, 1.0, 0.0001) r.free()- Step 3: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_input_router.gd -gexit
Expected: FAIL — InputRouter not found.
- Step 4: Implement InputRouter
Create input/input_router.gd:
class_name InputRouterextends Node
var _touch_vector: Vector2 = Vector2.ZERO
static func normalize_dir(raw: Vector2) -> Vector2: if raw.length() < 0.001: return Vector2.ZERO return raw.normalized()
func set_touch_vector(v: Vector2) -> void: _touch_vector = v
func poll() -> InputState: var raw := _touch_vector if raw.length() < 0.001: # Fall back to keyboard/gamepad actions when no touch input. raw = Input.get_vector("move_left", "move_right", "move_up", "move_down") return InputState.new(normalize_dir(raw))- Step 5: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_input_router.gd -gexit
Expected: PASS — 3 tests.
- Step 6: Commit
git add input/input_router.gd project.godot tests/test_input_router.gdgit commit -m "feat(input): InputRouter for keyboard/gamepad/touch → InputState"Task 15: Main scene wiring — playable loop + HUD + level-up + results
Section titled “Task 15: Main scene wiring — playable loop + HUD + level-up + results”Files:
- Create:
main.gd - Create:
main.tscn - Create:
ui/hud.gd - Create:
ui/level_up_panel.gd - Create:
ui/results_panel.gd
Interfaces:
-
Consumes everything above.
Main(extendsNode2D) builds its child nodes in_ready(code-built scene, version-controllable), drivessim.tick(input_router.poll())in_physics_process, and renders in_process. Whensim.pending_levelups > 0it pauses ticking and showsLevelUpPanel; whensim.game_overit showsResultsPanel. -
Step 1: Create the minimal main scene file
Create 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 2: Implement HUD
Create ui/hud.gd:
class_name Hudextends CanvasLayer
var _label: Label
func _ready() -> void: _label = Label.new() _label.position = Vector2(16, 12) _label.add_theme_font_size_override("font_size", 20) add_child(_label)
func update_hud(sim: Sim) -> void: var t := int(sim.run_time) _label.text = "Time %02d:%02d Lv %d HP %d/%d Kills %d Enemies %d" % [ t / 60, t % 60, sim.player.level, int(sim.player.hp), int(sim.player.max_hp), sim.kills, sim.enemies.count ]- Step 3: Implement LevelUpPanel
Create ui/level_up_panel.gd:
class_name LevelUpPanelextends CanvasLayer
signal chosen(id: String)
var _box: VBoxContainer
func _ready() -> void: layer = 10 var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) add_child(center) _box = VBoxContainer.new() center.add_child(_box) hide_panel()
func show_choices(ids: Array[String]) -> void: for c in _box.get_children(): c.queue_free() var title := Label.new() title.text = "LEVEL UP — choose an upgrade" _box.add_child(title) for id in ids: var entry := _find(id) var b := Button.new() b.text = "%s — %s" % [entry["name"], entry["desc"]] b.pressed.connect(func() -> void: chosen.emit(id)) _box.add_child(b) visible = true
func hide_panel() -> void: visible = false
func _find(id: String) -> Dictionary: for e in Upgrades.ALL: if e["id"] == id: return e return { "name": id, "desc": "" }- Step 4: Implement ResultsPanel
Create ui/results_panel.gd:
class_name ResultsPanelextends CanvasLayer
signal restart_requested
var _label: Label
func _ready() -> void: layer = 10 var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) add_child(center) var box := VBoxContainer.new() center.add_child(box) _label = Label.new() box.add_child(_label) var b := Button.new() b.text = "Play again" b.pressed.connect(func() -> void: restart_requested.emit()) box.add_child(b) visible = false
func show_results(sim: Sim) -> void: var t := int(sim.run_time) _label.text = "RUN OVER\nTime %02d:%02d Level %d Kills %d" % [t / 60, t % 60, sim.player.level, sim.kills] visible = true- Step 5: Implement Main wiring
Create main.gd:
extends Node2D
var sim: Simvar input_router: InputRoutervar camera: Camera2Dvar player_node: Node2Dvar enemy_renderer: SwarmRenderervar proj_renderer: SwarmRenderervar gem_renderer: SwarmRenderervar hud: Hudvar level_up: LevelUpPanelvar results: ResultsPanelvar _paused_for_levelup: bool = false
func _ready() -> void: _new_run()
func _new_run() -> void: for c in get_children(): c.queue_free() # Deterministic-ish seed derived from a fixed base; replace with a chosen seed later. sim = Sim.new(20260621) input_router = InputRouter.new() add_child(input_router)
add_child(ArenaBackground.new())
enemy_renderer = SwarmRenderer.new() enemy_renderer.configure(Sim.ENEMY_RADIUS, Color(1.0, 0.3, 0.5)) add_child(enemy_renderer)
proj_renderer = SwarmRenderer.new() proj_renderer.configure(6.0, Color(0.6, 0.95, 1.0)) add_child(proj_renderer)
gem_renderer = SwarmRenderer.new() gem_renderer.configure(Sim.GEM_RADIUS, Color(0.5, 1.0, 0.6)) add_child(gem_renderer)
player_node = Node2D.new() add_child(player_node) var pdot := SwarmRenderer.new() # reuse as a single-quad draw via Polygon2D instead: pdot.queue_free() var poly := Polygon2D.new() poly.polygon = PackedVector2Array([Vector2(0, -18), Vector2(15, 12), Vector2(-15, 12)]) poly.color = Color(0.9, 0.95, 1.0) player_node.add_child(poly)
camera = Camera2D.new() player_node.add_child(camera) camera.make_current()
hud = Hud.new() add_child(hud)
level_up = LevelUpPanel.new() add_child(level_up) level_up.chosen.connect(_on_upgrade_chosen)
results = ResultsPanel.new() add_child(results) results.restart_requested.connect(_new_run)
_paused_for_levelup = false
func _physics_process(_delta: float) -> void: if sim.game_over or _paused_for_levelup: return sim.tick(input_router.poll()) if sim.pending_levelups > 0: _open_levelup() if sim.game_over: results.show_results(sim)
func _process(_delta: float) -> void: player_node.position = sim.player.pos enemy_renderer.sync(sim.enemies, Color(1.0, 0.3, 0.5)) proj_renderer.sync(sim.projectiles, Color(0.6, 0.95, 1.0)) gem_renderer.sync(sim.gems, Color(0.5, 1.0, 0.6)) hud.update_hud(sim)
func _open_levelup() -> void: _paused_for_levelup = true level_up.show_choices(Upgrades.roll_choices(sim.rng, 3))
func _on_upgrade_chosen(id: String) -> void: sim.apply_upgrade(id) level_up.hide_panel() if sim.pending_levelups > 0: _open_levelup() else: _paused_for_levelup = false- Step 6: Boot smoke test (headless, must not crash)
Run:
godot --headless --path . --quit-after 120 2>&1 | tail -n 20; echo "exit=$?"Expected: the project boots, runs ~120 frames, and exits cleanly (exit=0) with no script errors in the output. (--quit-after runs N frames then quits.)
- Step 7: 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; echo "exit=$?"
Expected: ALL tests pass, exit=0.
- Step 8: Commit
git add main.gd main.tscn ui/hud.gd ui/level_up_panel.gd ui/results_panel.gdgit commit -m "feat: playable core loop — main wiring, HUD, level-up picker, results"- Step 9: Human playtest checkpoint
Open the project in the Godot editor (/Applications/Godot.app) and press Play (F5). Verify by hand:
- Ship moves with WASD; camera follows.
- The pulse weapon auto-fires at the nearest enemy; enemies die and drop green gems.
- Collecting gems fills XP; level-up panel appears and pauses the action; picking an upgrade resumes.
- Enemy density rises over time; standing in a crowd drains HP; HP=0 shows the results panel; “Play again” restarts.
(At this point, install the vetted zero-footprint Godot runtime MCP from the spec to enable agent-side screenshot/playtest verification for Milestone 2.)
Self-Review
Section titled “Self-Review”Spec coverage (against 2026-06-21-bullet-heaven-phase1-design.md):
- §2.1 one-way data flow → Tasks 6/14/13/15 (InputState → Sim.tick → renderers). ✓
- §2.1 constant-dt deterministic tick → Task 6 + Task 12 determinism test. ✓
- §2.2 hybrid entities (node player, data-oriented swarm) → Tasks 3/13/15. ✓
- §2.3 MultiMesh + glow → Task 13 (glow
WorldEnvironmentis a render polish item; grid + MultiMesh deliver the neon baseline; full bloom env is a Milestone-2 polish task, noted below). - §2.5 determinism rules (seeded RNG, constant dt, no engine physics, serializable state) → Tasks 2/6/4/10 + Task 12. ✓
- §3 core loop (move, spawn, kill, XP, level-up, ramp, death, results) → Tasks 5–11, 15. ✓
- §3 controls (stick + auto-fire/auto-target) → Tasks 9/14. ✓
- §4 build-craft layer 1 (weapon) + layer 2 stat mods → Tasks 9/12. (Transformative mods + evolutions are explicitly Milestone 2 — this plan delivers the framework they slot into.) ✓ within M1 scope
- §5 vertical slice: M1 delivers 1 weapon / 5 stat upgrades / 1 enemy type / arena / timed run plumbing. Weapons 2–5, enemies 2–5, evolutions, boss = Milestone 2+. ✓ (M1 is the foundation slice by design)
- §6 tooling/testing (GUT headless, context7, repo init, runtime MCP later) → Task 1 + Task 15 Step 9. ✓
- §7 multiplayer-aware decisions → satisfied structurally by Tasks 2/4/6/10/12. ✓
Deferred to Milestone 2 (explicitly, not gaps): glow WorldEnvironment bloom pass, GPUParticles2D juice, weapons 2–5, enemy types 2–5, transformative mods, weapon evolutions, mini-boss/boss, on-screen virtual joystick widget, results/endless polish. The M1 plan builds the framework each of these slots into.
Placeholder scan: no TBD/TODO; every code step shows complete code; every run step shows the exact command and expected result. ✓
Type consistency: EntityPool.data used consistently (enemy HP / projectile lifetime / gem XP — documented per task). Sim.tick(InputState), Sim_Const.DT, SeededRng.randf/randi_range/rand_unit_dir, Upgrades.apply/roll_choices, SwarmRenderer.configure/sync names match across all consuming tasks. ✓