M2 Cycle 9 — Enemy Variety & Weapons 3–5
M2 Cycle 9 — Enemy Variety & Weapons 3–5
Section titled “M2 Cycle 9 — Enemy Variety & Weapons 3–5”Date: 2026-06-23 Status: Approved
Add the four remaining enemy types (tank, shooter, splitter, elite) and three remaining weapons (orbit, beam, turret) from the Design Bible, making all seven live in-game. This completes the content pass planned for M2.
Section 1: EnemyPool — per-enemy columns
Section titled “Section 1: EnemyPool — per-enemy columns”EnemyPool gains five new parallel PackedArray columns, swap-removed in lockstep with existing columns (same pattern as aura_element/stacks/aura_remaining):
| Column | Type | Default on add |
|---|---|---|
armor |
PackedFloat32Array |
0.0 |
speed |
PackedFloat32Array |
from spawn def |
contact_dmg |
PackedFloat32Array |
from spawn def |
xp_val |
PackedFloat32Array |
from spawn def |
type_id |
PackedInt32Array |
TYPE_* constant |
Constants in EnemyPool:
const TYPE_SWARMER := 0const TYPE_TANK := 1const TYPE_SHOOTER := 2const TYPE_SPLITTER := 3const TYPE_ELITE := 4add() gains extra params: armor: float, speed: float, contact_dmg: float, xp_val: float, type_id: int.
Sim drops its uniform _enemy_hp, _enemy_speed, _contact_dps, _gem_xp scalars. enemy_radius is already per-entity in EntityPool.radius. Sim retains a _enemy_types: Array[Dictionary] built from ContentDB in _init, indexed by TYPE_* constant.
Section 2: Armor mechanic
Section titled “Section 2: Armor mechanic”Sim._damage_enemy(ei, amount) becomes:
var armor := enemies.armor[ei]var effective := maxf(amount - armor, amount * 0.1)enemies.data[ei] -= effective * _vuln_mult(ei)Armor never fully blocks — minimum 10% of original damage always lands.
Section 3: Multi-type spawning
Section titled “Section 3: Multi-type spawning”SpawnDirector gains pick_type(run_time: float, rng: SeededRng) -> int returning a TYPE_* constant. Threshold-based:
t < 30s → TYPE_SWARMER onlyt < 90s → 80% swarmer, 20% tankt < 180s → 60% swarmer, 20% tank, 20% elitet ≥ 180s → 35% swarmer, 20% tank, 20% elite, 15% splitter, 10% shooterSim._spawn_enemies calls pick_type, looks up the enemy def from _enemy_types, and passes all per-enemy stats to enemies.add(...).
Section 4: Enemy — tank
Section titled “Section 4: Enemy — tank”Bible data: hp=30, speed=40, radius=22, armor=4, contact_damage=18, xp_value=4.
No special behaviour — pure data. Spawns from t≥30s.
Section 5: Enemy — elite
Section titled “Section 5: Enemy — elite”Bible data: hp=60, speed=90, radius=26, armor=6, contact_damage=25, xp_value=12.
No special behaviour — fast charger, pure data. Spawns from t≥90s.
Section 6: Enemy — splitter
Section titled “Section 6: Enemy — splitter”Bible data: hp=10, speed=60, radius=14, armor=0, contact_damage=10, xp_value=3.
On death in _sweep_dead: if type_id[i] == TYPE_SPLITTER, spawn 2 swarmers at pos + Vector2(±20, 0) before removing. Children are TYPE_SWARMER with swarmer stats. No recursive split. Children are added directly — they do not count as kills.
Section 7: Enemy — shooter
Section titled “Section 7: Enemy — shooter”Bible data: hp=8, speed=55, radius=14, armor=0, contact_damage=10, xp_value=3.
Fires a projectile at the player every 2s.
Sim gains enemy_proj: EntityPool (cap 500). _update_shooters(dt) runs each tick before _resolve_collisions. It maintains _shooter_timers: Dictionary (enemy index → remaining cooldown). On fire: enemy_proj.add(pos, dir*300, 6, 3.0) (radius 6, lifetime 3s). _move_enemy_proj(dt) advances and despawns on lifetime ≤ 0. _check_player_hit gains a second loop: if player distance to enemy_proj ≤ player.radius + 6, remove projectile and deal 6 damage to player.
_shooter_timers is rebuilt each tick from range(enemies.count) filtered to TYPE_SHOOTER, preserving existing keys and initialising missing ones to 0.0.
Section 8: Weapon — WeaponOrbit
Section titled “Section 8: Weapon — WeaponOrbit”File: sim/weapon_orbit.gd
Three cold shards orbit at ORBIT_RADIUS = 120.0px, rotating at ORBIT_SPEED_DEG = 90.0 deg/s. Each tick, for each shard position, query hash for enemies within SHARD_HIT_RADIUS = 18.0px and deal base_damage * damage_mult * dt (continuous DPS). Apply cold element aura to each hit enemy.
class_name WeaponOrbitextends RefCounted
const ORBIT_RADIUS := 120.0const SHARD_HIT_RADIUS := 18.0const ORBIT_SPEED_DEG := 90.0const SHARD_COUNT := 3
var base_damage: floatvar _phase: float = 0.0
func _init(def: Dictionary) -> void: base_damage = float(def["base_damage"])
func update(sim: Sim, dt: float) -> void: _phase += deg_to_rad(ORBIT_SPEED_DEG) * dt sim.hash.rebuild(sim.enemies) for k in range(SHARD_COUNT): var angle := _phase + k * TAU / SHARD_COUNT var spos := sim.player.pos + Vector2(cos(angle), sin(angle)) * ORBIT_RADIUS var hits := sim.hash.query_circle(spos, SHARD_HIT_RADIUS, sim.enemies) var dmg := base_damage * sim.player.damage_mult * dt for ei in hits: sim._damage_enemy(ei, dmg) if sim.enemies.data[ei] > 0.0: var ev := Elemental.apply(sim.enemies, ei, sim.orbit_element_idx, sim.content, sim.mods) if not ev.is_empty(): sim._reaction_burst(ev["center"], ev["magnitude"], ev["generic"], sim.orbit_element_idx)sim.orbit_element_idx resolved at Sim._init via content.element_index("cold").
WeaponOrbit does not use fire_rate_mult — orbit speed is fixed (always-on DPS weapon, not a cooldown weapon). cooldown_frac() returns 1.0 always (panel shows full arc).
Section 9: Weapon — WeaponBeam
Section titled “Section 9: Weapon — WeaponBeam”File: sim/weapon_beam.gd
Pierce laser. Cooldown 0.1s (from bible cooldown_s: 0.1). On fire:
- Find nearest enemy (same scan as WeaponPulse).
- Compute direction toward it.
- Scan all enemies linearly; damage any whose perpendicular distance to the ray from
player.posindiris ≤BEAM_WIDTH = 20.0. - Apply light element to each hit.
- Emit
fx_eventsentry{kind: "beam", pos: player.pos, dir: dir, length: 900.0}for the renderer.
Perpendicular distance formula: (enemy_pos - player_pos).cross(dir).abs() where dir is normalised.
Only hit enemies whose projection onto the ray is positive (in front of the player, not behind).
const BEAM_WIDTH := 20.0const BEAM_RANGE := 900.0Light element index sim.beam_element_idx resolved at Sim._init via content.element_index("light").
Cooldown scales with fire_rate_mult. cooldown_frac() standard timer formula.
Section 10: Weapon — WeaponTurret
Section titled “Section 10: Weapon — WeaponTurret”File: sim/weapon_turret.gd
Deploys turrets at the player’s position. DEPLOY_COOLDOWN = 6.0s. Max 2 turrets active simultaneously. Each turret fires at nearest enemy every cooldown_s = 0.4s for base_damage * damage_mult kinetic damage (fires a normal projectile via sim.projectiles.add). Turret lifetime TURRET_LIFETIME = 8.0s.
class_name WeaponTurretextends RefCounted
const DEPLOY_COOLDOWN := 6.0const TURRET_LIFETIME := 8.0const MAX_TURRETS := 2const TURRET_PROJ_SPEED := 500.0const TURRET_PROJ_RADIUS := 6.0const TURRET_PROJ_LIFE := 1.5
var base_damage: floatvar cooldown_s: float # turret fire ratevar _deploy_timer: float = 0.0var _turrets: Array[Dictionary] = [] # {pos, life, fire_timer}update(sim, dt): advance _deploy_timer, deploy if ready and _turrets.size() < MAX_TURRETS; advance each turret’s life and fire_timer; fire toward nearest enemy when ready; remove turrets with life ≤ 0. Finding nearest enemy: same O(n) scan as WeaponPulse — use sim.enemies.
sim.proj_damage is set by WeaponPulse each frame. Turret fires its own damage independently — call sim.projectiles.add(...) directly and handle damage in _resolve_collisions using sim.proj_damage? No — turret damage differs from pulse damage. Introduce sim.turret_proj_damage: float, set by WeaponTurret before each shot. _resolve_collisions uses sim.proj_damage for all projectiles — this is a shared field.
Simpler approach: turret projectiles use the same proj_damage field. WeaponTurret sets sim.proj_damage = base_damage * sim.player.damage_mult before each turret shot, then immediately restores it. Pulse sets it fresh each time it fires anyway.
Kinetic element → no aura. Turret shots don’t trigger elemental application (skip Elemental.apply for turret projectiles). To distinguish: turret fires a projectile with a sentinel data value slightly different from pulse’s? No, that’s fragile.
Simpler: Accept that turret and pulse projectiles share the same pool and proj_damage. This means proj_damage is “damage of the most recently configured shot.” Since turret fires sequentially (never mid-pulse) and the sim is deterministic single-threaded, this is fine in practice. Turret sets sim.proj_damage before sim.projectiles.add(...), and _resolve_collisions uses whatever proj_damage is when each projectile hits — which is the last-set value. Since projectiles persist across ticks, this is actually wrong.
Correct approach: Store damage per projectile. Add a second pool or use a second data field. But EntityPool.data is used for lifetime.
Best approach: Add damage: PackedFloat32Array to the projectiles pool (a new EntityPool column). Each projectiles.add() also sets damage[i]. _resolve_collisions reads projectiles.damage[pi] instead of sim.proj_damage.
This means extending EntityPool with a damage column, or creating a subclass ProjPool (analogous to EnemyPool). Use ProjPool extends EntityPool with an extra damage: PackedFloat32Array.
sim.proj_damage becomes unused and is removed.
Section 11: Sim wiring
Section titled “Section 11: Sim wiring”Sim._init:
- Build
_enemy_types: Array[Dictionary]from content (TYPE_SWARMER=0 … TYPE_ELITE=4) - Resolve
orbit_element_idx,beam_element_idxviacontent.element_index() - Construct
orbit: WeaponOrbit,beam: WeaponBeam,turret: WeaponTurretifcontent.has_weapon(id) - Replace
projectiles: EntityPoolwithprojectiles: ProjPool - Add
enemy_proj: EntityPool - Add
_shooter_timers: Dictionary
Tick order (additions only, rest unchanged):
...nova.update → orbit.update → beam.update → turret.update→ _move_projectiles → _move_enemy_proj → _update_shooters→ _resolve_collisions → ...Section 12: Renderer / UI
Section titled “Section 12: Renderer / UI”WeaponPanel: Add slots for orbit, beam, turret (total 5 weapons). Panel widens or wraps to fit. Each slot calls cooldown_frac(). Orbit slot always shows full arc (always active).
FxManager: Beam event {kind:"beam", pos, dir, length} renders as a brief bright line (use draw_line on a Node2D, fade out over 0.1s). Simple additive flash — one pooled line node is enough.
No other render changes needed (orbital shards, turret projectiles, enemy projectiles all use existing swarm/projectile renderers if wired to the correct MultiMesh, or can be rendered as separate simple shapes).
Section 13: Bible — mark live
Section titled “Section 13: Bible — mark live”In tools/design-bible/src/seed.js, add live: true to all 7 new entries:
- weapons:
orbit,beam,turret - enemies:
tank,shooter,splitter,elite
Re-export data/bible.json via node tools/design-bible/scripts/export-seed.mjs > data/bible.json.
Section 14: Determinism baseline update
Section titled “Section 14: Determinism baseline update”After all tasks pass, record new checksums:
- Run
tests/test_determinism_checksum.gdand updateEXPECTED_HASH/EXPECTED_CHECKSUMin that file - Update
CLAUDE.mdwith the new values
Files changed
Section titled “Files changed”| File | Change |
|---|---|
sim/enemy_pool.gd |
+5 parallel columns, updated add/remove_at, TYPE_* constants |
sim/entity_pool.gd |
No change — subclassed instead |
sim/proj_pool.gd |
New: ProjPool extends EntityPool with damage column |
sim/weapon_orbit.gd |
New |
sim/weapon_beam.gd |
New |
sim/weapon_turret.gd |
New |
sim/sim.gd |
Wire all new weapons/enemies, add enemy_proj, _shooter_timers, per-enemy spawn |
sim/spawn_director.gd |
Add pick_type(run_time, rng) |
ui/weapon_panel.gd |
Extend to show 5 weapon slots |
tools/design-bible/src/seed.js |
Mark 7 entries live: true |
data/bible.json |
Re-export |
tests/test_determinism_checksum.gd |
Update expected checksums |
tests/test_enemy_pool.gd |
New: covers new columns and armor |
tests/test_proj_pool.gd |
New: covers ProjPool |
tests/test_weapon_orbit.gd |
New |
tests/test_weapon_beam.gd |
New |
tests/test_weapon_turret.gd |
New |
tests/test_spawn_director.gd |
Extend: covers pick_type |
Testing
Section titled “Testing”- GUT headless: all existing 150 tests must still pass; new tests added per file
- Headless boot smoke:
godot --headless --path . --quit-after 300 - Determinism:
tests/test_determinism.gdproperty test still passes; checksum test updated with new baseline