Skip to content

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).

  • 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-clock delta inside sim logic. All randomness flows through one SeededRng. No engine physics for swarm entities.
  • Layering: /sim files MUST NOT reference /render, /input, /ui, or any visual Node API (no CanvasItem, Node2D drawing, get_viewport, etc.). /sim depends only on core types (Vector2, PackedArray*, RefCounted, Resource). This keeps the sim headless-testable and serializable.
  • Tests: every /sim unit 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:).

project.godot # engine config: physics tick 60, main scene, mobile renderer
.gutconfig.json # GUT headless config
addons/gut/ # GUT 9.6.0 (vendored, not authored)
sim/constants.gd # Sim-wide constants (DT, ARENA_HALF) — class_name Sim_Const
sim/seeded_rng.gd # class_name SeededRng — deterministic RNG
sim/input_state.gd # class_name InputState — per-player input data
sim/entity_pool.gd # class_name EntityPool — flat pool with swap-remove
sim/spatial_hash.gd # class_name SpatialHash — uniform grid neighbour queries
sim/player_state.gd # class_name PlayerState — player data + integrate()
sim/spawn_director.gd # class_name SpawnDirector — time-based enemy schedule
sim/weapon_pulse.gd # class_name WeaponPulse — auto-fire homing shot
sim/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 driver
render/arena_background.gd # neon grid background draw
input/input_router.gd # keyboard/gamepad/touch → InputState
ui/hud.gd # xp bar, timer, hp
ui/level_up_panel.gd # upgrade picker
ui/results_panel.gd # end-of-run summary
main.gd # wires sim+render+input; _physics_process drives sim
tests/test_seeded_rng.gd
tests/test_entity_pool.gd
tests/test_spatial_hash.gd
tests/test_player_state.gd
tests/test_sim_core.gd
tests/test_spawn_director.gd
tests/test_weapon_pulse.gd
tests/test_collision_damage.gd
tests/test_xp_levelup.gd
tests/test_upgrades.gd
tests/test_determinism.gd

Task 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, /tests directory 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:

Terminal window
cd /Users/chris/Claude/bullet-heaven
git clone --depth 1 --branch v9.6.0 https://github.com/bitwes/Gut.git /tmp/gut-src
mkdir -p addons
cp -R /tmp/gut-src/addons/gut addons/gut
rm -rf /tmp/gut-src
ls addons/gut/gut_cmdln.gd

Expected: 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:

Terminal window
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
Terminal window
git add -A
git commit -m "chore: Godot 4.6 project + vendored GUT 9.6.0 headless harness"

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.0
    • SeededRng.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_Const
extends RefCounted
const DT: float = 1.0 / 60.0
const 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 SeededRng
extends 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
Terminal window
git add sim/constants.gd sim/seeded_rng.gd tests/test_seeded_rng.gd
git 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) -> int returns the new index, or -1 if full.
    • remove_at(i: int) -> void swap-removes: moves the last active entity into slot i, decrements count.
  • Note for consumers: remove_at invalidates index count-1’s identity (it moves to i). 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 EntityPool
extends RefCounted
var capacity: int
var count: int = 0
var pos: PackedVector2Array
var vel: PackedVector2Array
var radius: PackedFloat32Array
var 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
Terminal window
git add sim/entity_pool.gd tests/test_entity_pool.gd
git 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 by pool.pos.
    • query_circle(center: Vector2, r: float, pool: EntityPool) -> PackedInt32Array — returns indices whose position is within r of center (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 SpatialHash
extends RefCounted
var _cell_size: float
var _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
Terminal window
git add sim/spatial_hash.gd tests/test_spatial_hash.gd
git 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 by move_dir * speed * dt, clamps to ±Sim_Const.ARENA_HALF.
  • Step 1: Create InputState (dependency)

Create sim/input_state.gd:

class_name InputState
extends 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 PlayerState
extends RefCounted
var pos: Vector2 = Vector2.ZERO
var hp: float = 100.0
var max_hp: float = 100.0
var speed: float = 260.0
var pickup_radius: float = 90.0
var radius: float = 16.0
var level: int = 1
var xp: float = 0.0
var xp_to_next: float = 10.0
var damage_mult: float = 1.0
var 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
Terminal window
git add sim/input_state.gd sim/player_state.gd tests/test_player_state.gd
git 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 using Sim_Const.DT: integrates the player, advances run_time. (Enemy/weapon/collision behaviour is added in later tasks; method body grows.)
  • Later tasks (7–12) extend tick() and add helper methods on Sim. Their tests instantiate Sim and drive it through tick().

  • 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 Sim
extends RefCounted
const ENEMY_CAP: int = 6000
const PROJ_CAP: int = 4000
const GEM_CAP: int = 4000
const HASH_CELL: float = 64.0
var rng: SeededRng
var player: PlayerState
var enemies: EntityPool
var projectiles: EntityPool
var gems: EntityPool
var hash: SpatialHash
var run_time: float = 0.0
var kills: int = 0
var 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
Terminal window
git add sim/sim.gd tests/test_sim_core.gd
git commit -m "feat(sim): Sim core with constant-dt tick, pools, run timer"

Files:

  • Create: sim/spawn_director.gd
  • Modify: sim/sim.gd (call the director in tick, 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.5 enemies/sec; carries fractional remainder in accum.
  • Modifies Sim: add spawner: SpawnDirector, _spawn_accum: float = 0.0, and in tick spawn that many enemies at a random point on a circle of radius SPAWN_RING := 1100.0 around the player. Enemy entry: enemies.add(spawn_pos, Vector2.ZERO, ENEMY_RADIUS, ENEMY_HP) with ENEMY_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 SpawnDirector
extends 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.0
const ENEMY_RADIUS: float = 14.0
const ENEMY_HP: float = 3.0

Add fields near the other vars:

var spawner: SpawnDirector
var _spawn_accum: float = 0.0

In _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
Terminal window
git add sim/spawn_director.gd sim/sim.gd tests/test_spawn_director.gd
git commit -m "feat(sim): time-escalating spawn director + enemy spawning"

Files:

  • Modify: sim/sim.gd (add _move_enemies, call in tick)
  • Test: tests/test_enemy_chase.gd

Interfaces:

  • Modifies Sim: add ENEMY_SPEED := 70.0; _move_enemies(dt) moves every active enemy toward player.pos at ENEMY_SPEED. Called in tick after 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.0

In 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
Terminal window
git add sim/sim.gd tests/test_enemy_chase.gd
git 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 a WeaponPulse, call in tick, add _move_projectiles)
  • Test: tests/test_weapon_pulse.gd

Interfaces:

  • Produces WeaponPulse.new():

    • cooldown: float = 0.6 (seconds between shots, divided by player.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. Projectile data field stores remaining lifetime; damage is read from base_damage * player.damage_mult at fire time and encoded by spawning the projectile with data = lifetime while 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 data holds remaining lifetime. Projectile damage for Milestone 1 is the constant WeaponPulse.base_damage * player.damage_mult evaluated in the collision step via sim.proj_damage (a field the weapon sets when it fires). Store sim.proj_damage: float updated on each shot.

  • Modifies Sim: add weapon: WeaponPulse, proj_damage: float = 1.0, PROJ_OFFSCREEN_CULL handled by lifetime. _move_projectiles(dt) advances projectiles and decrements data (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 WeaponPulse
extends RefCounted
var cooldown: float = 0.6
var proj_speed: float = 520.0
var proj_radius: float = 6.0
var base_damage: float = 1.0
var proj_lifetime: float = 1.4
var _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: WeaponPulse
var proj_damage: float = 1.0

In _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
Terminal window
git add sim/weapon_pulse.gd sim/sim.gd tests/test_weapon_pulse.gd
git 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 in tick)
  • Test: tests/test_collision_damage.gd

Interfaces:

  • Modifies Sim: add GEM_RADIUS := 8.0, GEM_XP := 1.0. _resolve_collisions():

    • Rebuilds hash from enemies.
    • For each projectile (backwards), query_circle(proj_pos, proj_radius + ENEMY_RADIUS, enemies); on first hit, subtract proj_damage from that enemy’s data (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 tick after _move_projectiles.
  • 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.0
const GEM_XP: float = 1.0

In 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
Terminal window
git add sim/sim.gd tests/test_collision_damage.gd
git 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: add pending_levelups: int = 0, CONTACT_DPS := 12.0.

    • _collect_gems() — gems within player.pickup_radius are collected: player.xp += gem.data, gem removed. While player.xp >= player.xp_to_next: subtract, player.level += 1, player.xp_to_next *= 1.35, pending_levelups += 1.
    • _check_player_hit(dt) — any enemy within player.radius + ENEMY_RADIUS drains CONTACT_DPS * dt * <count> HP; if player.hp <= 0, set game_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 = 0
const CONTACT_DPS: float = 12.0

In 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
Terminal window
git add sim/sim.gd tests/test_xp_levelup.gd
git 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 (add apply_upgrade, consume pending_levelups)
  • Test: tests/test_upgrades.gd
  • Test: tests/test_determinism.gd

Interfaces:

  • Produces Upgrades with 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), regen placeholder excluded.
    • Upgrades.apply(id: String, player: PlayerState) -> void.
    • Upgrades.roll_choices(rng: SeededRng, n: int) -> Array[String] — returns n distinct random upgrade ids.
  • Modifies Sim: apply_upgrade(id: String) -> void calls Upgrades.apply and decrements pending_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 Upgrades
extends 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
Terminal window
git add sim/upgrades.gd sim/sim.gd tests/test_upgrades.gd tests/test_determinism.gd
git 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 (extends MultiMeshInstance2D):

    • configure(mesh_radius: float, color: Color) -> void — builds a QuadMesh of size mesh_radius*2, a MultiMesh with transform_format = TRANSFORM_2D, use_colors = true.
    • sync(pool: EntityPool, color: Color) -> void — sets multimesh.instance_count = pool.count and writes each instance’s transform_2d (translation = pool.pos[i]) and color.
  • This is the first Node-based file; it MAY use Node/MultiMesh APIs. It reads EntityPool but 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 SwarmRenderer
extends 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 ArenaBackground
extends Node2D
const STEP: float = 100.0
const 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
Terminal window
git add render/swarm_renderer.gd render/arena_background.gd tests/test_swarm_renderer.gd
git 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 (extends Node):

    • poll() -> InputState — reads movement: keyboard/gamepad via the move_* actions OR an injected virtual-joystick vector (set_touch_vector(v: Vector2)), returns an InputState with a normalized move_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 live Input singleton 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 InputRouter
extends 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
Terminal window
git add input/input_router.gd project.godot tests/test_input_router.gd
git 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 (extends Node2D) builds its child nodes in _ready (code-built scene, version-controllable), drives sim.tick(input_router.poll()) in _physics_process, and renders in _process. When sim.pending_levelups > 0 it pauses ticking and shows LevelUpPanel; when sim.game_over it shows ResultsPanel.

  • 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 Hud
extends 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 LevelUpPanel
extends 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 ResultsPanel
extends 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: Sim
var input_router: InputRouter
var camera: Camera2D
var player_node: Node2D
var enemy_renderer: SwarmRenderer
var proj_renderer: SwarmRenderer
var gem_renderer: SwarmRenderer
var hud: Hud
var level_up: LevelUpPanel
var results: ResultsPanel
var _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:

Terminal window
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
Terminal window
git add main.gd main.tscn ui/hud.gd ui/level_up_panel.gd ui/results_panel.gd
git 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.)


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 WorldEnvironment is 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. ✓