Skip to content

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 := 0
const TYPE_TANK := 1
const TYPE_SHOOTER := 2
const TYPE_SPLITTER := 3
const TYPE_ELITE := 4

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


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.


SpawnDirector gains pick_type(run_time: float, rng: SeededRng) -> int returning a TYPE_* constant. Threshold-based:

t < 30s → TYPE_SWARMER only
t < 90s → 80% swarmer, 20% tank
t < 180s → 60% swarmer, 20% tank, 20% elite
t ≥ 180s → 35% swarmer, 20% tank, 20% elite, 15% splitter, 10% shooter

Sim._spawn_enemies calls pick_type, looks up the enemy def from _enemy_types, and passes all per-enemy stats to enemies.add(...).


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.


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.


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.


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.


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 WeaponOrbit
extends RefCounted
const ORBIT_RADIUS := 120.0
const SHARD_HIT_RADIUS := 18.0
const ORBIT_SPEED_DEG := 90.0
const SHARD_COUNT := 3
var base_damage: float
var _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).


File: sim/weapon_beam.gd

Pierce laser. Cooldown 0.1s (from bible cooldown_s: 0.1). On fire:

  1. Find nearest enemy (same scan as WeaponPulse).
  2. Compute direction toward it.
  3. Scan all enemies linearly; damage any whose perpendicular distance to the ray from player.pos in dir is ≤ BEAM_WIDTH = 20.0.
  4. Apply light element to each hit.
  5. Emit fx_events entry {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.0
const BEAM_RANGE := 900.0

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


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 WeaponTurret
extends RefCounted
const DEPLOY_COOLDOWN := 6.0
const TURRET_LIFETIME := 8.0
const MAX_TURRETS := 2
const TURRET_PROJ_SPEED := 500.0
const TURRET_PROJ_RADIUS := 6.0
const TURRET_PROJ_LIFE := 1.5
var base_damage: float
var cooldown_s: float # turret fire rate
var _deploy_timer: float = 0.0
var _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.


Sim._init:

  • Build _enemy_types: Array[Dictionary] from content (TYPE_SWARMER=0 … TYPE_ELITE=4)
  • Resolve orbit_element_idx, beam_element_idx via content.element_index()
  • Construct orbit: WeaponOrbit, beam: WeaponBeam, turret: WeaponTurret if content.has_weapon(id)
  • Replace projectiles: EntityPool with projectiles: 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 → ...

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


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.


After all tasks pass, record new checksums:

  • Run tests/test_determinism_checksum.gd and update EXPECTED_HASH / EXPECTED_CHECKSUM in that file
  • Update CLAUDE.md with the new values

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

  • 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.gd property test still passes; checksum test updated with new baseline