Skip to content

Centurion Enemy 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: Add a new ranged enemy, Centurion, whose visual identity is a genuinely colorless procedural shape — the first real demonstration in this codebase of the Nova Drift “white base art × per-instance multiplicative color” technique — automatically re-tinted per Elemental Dimension by plumbing that already exists.

Architecture: Centurion is an ordinary bible-backed EnemyPool type (TYPE_CENTURION = 23), reusing the Shooter’s existing aimed-fire behavior (sim/enemy_attacks.gd:update_shooters) and a new procedural silhouette shape (SHAPE_ROSETTE in render/archetype_renderer.gd, built from the same generalized _star_pts polygon function SHAPE_STAR already uses, just tuned to a shallow scallop instead of a sharp star). Because it is never given a fixed identity color, it automatically falls through main.gd’s existing generic per-element color branch — no new tinting code at all. Spawn eligibility (separate from color) is gated in three places: sim/area_defs.gd’s per-dimension enemy_types allow-list (which every spawn path checks before color is ever considered), sim/spawn_table.gd (the primary per-wave spawner), and sim/spawn_director.gd (the secondary swarm-burst event).

Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6.0 test framework.

  • TYPE_CENTURION := 23 (next free id after TYPE_CUSTOM = 22) — used verbatim in every task below.
  • Determinism baseline (tests/test_determinism_checksum.gd, tests/test_determinism_crystals.gd): snapshot_string().hash() = 4217109746, state_checksum() = 2666143677. Every change in this plan is deliberately placed outside the first 600 ticks / 10 sim-seconds this baseline covers — Task 6 verifies it actually held, not just “should.”
  • data/bible.json is hand-maintained and has drifted ahead of tools/design-bible/src/seed.js — never regenerate it from that script; only hand-edit.
  • All indentation in .gd and .json files in this repo is tabs, not spaces — match the surrounding file exactly.
  • Design spec: docs/superpowers/specs/2026-07-06-centurion-enemy-design.md (read for the full rationale; this plan is the executable version of it).

Task 1: Content data — bible.json + bestiary.json entries

Section titled “Task 1: Content data — bible.json + bestiary.json entries”

Files:

  • Modify: data/bible.json (append to data.enemies array)
  • Modify: data/bestiary.json (append to entries object)
  • Test: tests/test_new_enemies.gd (append a new section)

Interfaces:

  • Produces: a bible entry queryable via ContentDB.enemy("centurion") returning a Dictionary with keys id, name, archetype, hp, speed, radius, contact_damage, xp_value, armor, element, color, tags, live, biomass — consumed by Task 2’s _build_enemy_types.

  • Step 1: Write the failing test

Append to tests/test_new_enemies.gd (after the existing “Orbiter + Lancer” section at the end of the file):

# ── Centurion ────────────────────────────────────────────────────────────────
func test_centurion_bible_entry_exists() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var e := content.enemy("centurion")
assert_false(e.is_empty(), "centurion entry exists in bible.json")
assert_eq(e.get("element", ""), "aether", "centurion home element is aether")
assert_eq(e.get("archetype", ""), "ranged", "centurion is a ranged archetype")
assert_almost_eq(float(e.get("hp", 0.0)), 14.0, 0.001)
assert_almost_eq(float(e.get("speed", 0.0)), 45.0, 0.001)
assert_almost_eq(float(e.get("armor", 0.0)), 2.0, 0.001)
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_new_enemies -gexit Expected: FAIL — centurion entry exists in bible.json fails because e.is_empty() is true (no such entry yet).

  • Step 3: Add the bible.json entry

In data/bible.json, find the tank_missile enemy entry (search for "id": "tank_missile") and insert a new entry immediately after its closing }, and before the "funzo" entry. Match the file’s existing tab indentation exactly (3 tabs for the entry’s {, 4 tabs for its fields, 5 tabs for array items — copy the indentation from the surrounding tank_missile/funzo entries):

{
"id": "centurion",
"name": "Centurion",
"archetype": "ranged",
"hp": 14,
"speed": 45,
"radius": 14,
"contact_damage": 10,
"xp_value": 4,
"armor": 2,
"element": "aether",
"color": "#c8d2dc",
"tags": [
"ranged"
],
"live": true,
"biomass": 4
},
  • Step 4: Add the bestiary.json entry

In data/bestiary.json, the top-level entries key is a Dictionary keyed by bible id (e.g. "shooter": {...}). Add a "centurion" key alongside the existing entries, matching the schema used by "shooter" (name, threat, move, attack, desc, counter):

"centurion": {
"name": "Centurion",
"threat": 2,
"move": "stationary",
"attack": "bolt",
"desc": "An armored, plated sentry that fires from range.",
"counter": "Close the gap or strafe perpendicular to its line of fire."
},
  • Step 5: Run test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_new_enemies -gexit Expected: PASS.

  • Step 6: Commit
Terminal window
git add data/bible.json data/bestiary.json tests/test_new_enemies.gd
git commit -m "feat(content): add Centurion enemy bible + bestiary entries"

Files:

  • Modify: sim/enemy_pool.gd:26,29-32 (add TYPE_CENTURION, extend TYPE_NAMES)
  • Modify: sim/sim.gd:546-608 (extend _build_enemy_types, remove the now-redundant TYPE_CUSTOM “safe defaults” block)
  • Test: tests/test_new_enemies.gd (append)

Interfaces:

  • Consumes: content.enemy("centurion") from Task 1.

  • Produces: EnemyPool.TYPE_CENTURION (int, value 23), EnemyPool.type_name(TYPE_CENTURION) == "centurion", sim._enemy_types[EnemyPool.TYPE_CENTURION] a non-empty Dictionary with the Task 1 stats — consumed by Tasks 3, 4, 5.

  • Step 1: Write the failing tests

Append to the Centurion section of tests/test_new_enemies.gd (after Task 1’s tests):

func test_centurion_type_constant() -> void:
assert_eq(EnemyPool.TYPE_CENTURION, 23, "TYPE_CENTURION must be 23 (next free id after TYPE_CUSTOM=22)")
func test_centurion_type_name() -> void:
assert_eq(EnemyPool.type_name(EnemyPool.TYPE_CENTURION), "centurion")
func test_centurion_builds_in_sim() -> void:
var sim := _sim()
assert_false(sim._enemy_types[EnemyPool.TYPE_CENTURION].is_empty(), "Centurion has a bible entry wired into _enemy_types")
assert_almost_eq(float(sim._enemy_types[EnemyPool.TYPE_CENTURION]["hp"]), 14.0, 0.001)
func test_centurion_base_element_is_aether() -> void:
var sim := _sim()
var el := sim.content.element_index("aether")
assert_eq(sim._enemy_base_el[EnemyPool.TYPE_CENTURION], el, "Centurion's cached base element is aether")
# Regression: TYPE_CUSTOM's per-type defaults (used when a dev-spawned custom enemy has
# BEHAVIOR_DASH) must be unchanged now that TYPE_CUSTOM is folded into the main
# _build_enemy_types loop instead of a separate post-hoc "safe defaults" block.
func test_custom_type_defaults_unchanged_after_centurion_wiring() -> void:
var sim := _sim()
assert_almost_eq(sim._dash_charge[EnemyPool.TYPE_CUSTOM], 0.9, 0.0001)
assert_almost_eq(sim._dash_lunge[EnemyPool.TYPE_CUSTOM], 0.32, 0.0001)
assert_almost_eq(sim._dash_speed[EnemyPool.TYPE_CUSTOM], 540.0, 0.0001)
assert_eq(sim._enemy_base_el[EnemyPool.TYPE_CUSTOM], -1)
assert_eq(sim._enemy_behavior[EnemyPool.TYPE_CUSTOM], EnemyPool.BEHAVIOR_WALK)
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_new_enemies -gexit Expected: FAIL — EnemyPool.TYPE_CENTURION doesn’t exist yet (parse/identifier error).

  • Step 3: Add the type constant and name

In sim/enemy_pool.gd, change:

const TYPE_CUSTOM := 22 # dev-built enemy: identity from per-enemy shape_id/attack_id, not a fixed type
# Short stable name per type id — for telemetry attribution (death cause). Pure data.
const TYPE_NAMES := ["swarmer", "pyromancer", "shooter", "splitter", "charger", "spider",
"boss", "skirmisher", "brute", "rusher", "zapper", "scatterer", "bomber",
"orbiter", "lancer", "boss2", "ghost", "accumulator", "fireball", "funzo",
"graviton", "eye", "custom"]

to:

const TYPE_CUSTOM := 22 # dev-built enemy: identity from per-enemy shape_id/attack_id, not a fixed type
const TYPE_CENTURION := 23 # armored ranged sentry — scalloped procedural disc, per-dimension multiply-tinted
# Short stable name per type id — for telemetry attribution (death cause). Pure data.
const TYPE_NAMES := ["swarmer", "pyromancer", "shooter", "splitter", "charger", "spider",
"boss", "skirmisher", "brute", "rusher", "zapper", "scatterer", "bomber",
"orbiter", "lancer", "boss2", "ghost", "accumulator", "fireball", "funzo",
"graviton", "eye", "custom", "centurion"]
  • Step 4: Extend _build_enemy_types in sim/sim.gd

Change (the resize line and the full list of assignments, plus removing the now-redundant block below the loop):

func _build_enemy_types() -> void:
_enemy_types.resize(EnemyPool.TYPE_EYE + 1)
_enemy_types[EnemyPool.TYPE_SWARMER] = content.enemy("swarmer")
_enemy_types[EnemyPool.TYPE_TANK] = content.enemy("tank")
_enemy_types[EnemyPool.TYPE_SHOOTER] = content.enemy("shooter")
_enemy_types[EnemyPool.TYPE_SPLITTER] = content.enemy("splitter")
_enemy_types[EnemyPool.TYPE_ELITE] = content.enemy("elite")
_enemy_types[EnemyPool.TYPE_SPIDER] = content.enemy("spider")
_enemy_types[EnemyPool.TYPE_BOSS] = {} # boss is spawned manually, not from the bible table
_enemy_types[EnemyPool.TYPE_SKIRMISHER] = content.enemy("skirmisher")
_enemy_types[EnemyPool.TYPE_BRUTE] = content.enemy("brute")
_enemy_types[EnemyPool.TYPE_RUSHER] = content.enemy("rusher")
_enemy_types[EnemyPool.TYPE_ZAPPER] = content.enemy("zapper")
_enemy_types[EnemyPool.TYPE_SCATTERER] = content.enemy("scatterer")
_enemy_types[EnemyPool.TYPE_BOMBER] = content.enemy("bomber")
_enemy_types[EnemyPool.TYPE_ORBITER] = content.enemy("orbiter")
_enemy_types[EnemyPool.TYPE_LANCER] = content.enemy("lancer")
_enemy_types[EnemyPool.TYPE_BOSS2] = {} # boss2 is spawned manually, not from the bible table
_enemy_types[EnemyPool.TYPE_GHOST] = content.enemy("ghost")
_enemy_types[EnemyPool.TYPE_ACCUMULATOR] = content.enemy("accumulator")
_enemy_types[EnemyPool.TYPE_TANK_MISSILE] = content.enemy("tank_missile")
_enemy_types[EnemyPool.TYPE_FUNZO] = {} # funzo is spawned manually (pooled boss)
_enemy_types[EnemyPool.TYPE_GRAVITON] = {} # graviton is spawned manually (pooled boss)
_enemy_types[EnemyPool.TYPE_EYE] = {} # eye is spawned manually (pooled boss)
var n := _enemy_types.size()

to:

func _build_enemy_types() -> void:
_enemy_types.resize(EnemyPool.TYPE_CENTURION + 1)
_enemy_types[EnemyPool.TYPE_SWARMER] = content.enemy("swarmer")
_enemy_types[EnemyPool.TYPE_TANK] = content.enemy("tank")
_enemy_types[EnemyPool.TYPE_SHOOTER] = content.enemy("shooter")
_enemy_types[EnemyPool.TYPE_SPLITTER] = content.enemy("splitter")
_enemy_types[EnemyPool.TYPE_ELITE] = content.enemy("elite")
_enemy_types[EnemyPool.TYPE_SPIDER] = content.enemy("spider")
_enemy_types[EnemyPool.TYPE_BOSS] = {} # boss is spawned manually, not from the bible table
_enemy_types[EnemyPool.TYPE_SKIRMISHER] = content.enemy("skirmisher")
_enemy_types[EnemyPool.TYPE_BRUTE] = content.enemy("brute")
_enemy_types[EnemyPool.TYPE_RUSHER] = content.enemy("rusher")
_enemy_types[EnemyPool.TYPE_ZAPPER] = content.enemy("zapper")
_enemy_types[EnemyPool.TYPE_SCATTERER] = content.enemy("scatterer")
_enemy_types[EnemyPool.TYPE_BOMBER] = content.enemy("bomber")
_enemy_types[EnemyPool.TYPE_ORBITER] = content.enemy("orbiter")
_enemy_types[EnemyPool.TYPE_LANCER] = content.enemy("lancer")
_enemy_types[EnemyPool.TYPE_BOSS2] = {} # boss2 is spawned manually, not from the bible table
_enemy_types[EnemyPool.TYPE_GHOST] = content.enemy("ghost")
_enemy_types[EnemyPool.TYPE_ACCUMULATOR] = content.enemy("accumulator")
_enemy_types[EnemyPool.TYPE_TANK_MISSILE] = content.enemy("tank_missile")
_enemy_types[EnemyPool.TYPE_FUNZO] = {} # funzo is spawned manually (pooled boss)
_enemy_types[EnemyPool.TYPE_GRAVITON] = {} # graviton is spawned manually (pooled boss)
_enemy_types[EnemyPool.TYPE_EYE] = {} # eye is spawned manually (pooled boss)
_enemy_types[EnemyPool.TYPE_CUSTOM] = {} # custom enemies are built at runtime from shape_id/attack_id, not the bible table
_enemy_types[EnemyPool.TYPE_CENTURION] = content.enemy("centurion")
var n := _enemy_types.size()

Then find and DELETE this now-redundant block (it used to extend the per-type arrays past _enemy_types’ old boundary at TYPE_EYE to also cover TYPE_CUSTOM; _enemy_types now already extends to TYPE_CENTURION and the main loop below already assigns identical defaults for TYPE_CUSTOM via its {} entry above — {}.get("charge_s", 0.9) etc. produce the exact same values this block used to hardcode. Leaving this block in place would be an active bug: it resizes the arrays back down to TYPE_CUSTOM + 1 = 23, silently truncating away the TYPE_CENTURION (index 23) data the main loop just wrote):

# Extend per-type arrays to cover TYPE_CUSTOM (22) with safe defaults so that
# custom enemies with BEHAVIOR_DASH don't go out of bounds in enemy_behaviors.step_dash.
var custom_cap := EnemyPool.TYPE_CUSTOM + 1
_dash_charge.resize(custom_cap)
_dash_lunge.resize(custom_cap)
_dash_speed.resize(custom_cap)
_enemy_base_el.resize(custom_cap)
_enemy_behavior.resize(custom_cap)
_dash_charge[EnemyPool.TYPE_CUSTOM] = 0.9
_dash_lunge[EnemyPool.TYPE_CUSTOM] = 0.32
_dash_speed[EnemyPool.TYPE_CUSTOM] = 540.0
_enemy_base_el[EnemyPool.TYPE_CUSTOM] = -1
_enemy_behavior[EnemyPool.TYPE_CUSTOM] = EnemyPool.BEHAVIOR_WALK

Do not touch anything after this deleted block (the accumulator base-stats caching that follows is unrelated and stays as-is).

  • Step 5: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_new_enemies -gexit Expected: PASS, all 5 new tests plus the pre-existing ones in this file.

  • Step 6: Run the full suite as a regression check

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: the “Run Summary” printed before the known headless-teardown SIGABRT shows the same pass count as before this task, plus the 5 new tests — no new failures anywhere (this change touches shared Sim bootstrapping, so any regression would likely show up broadly, not just in test_new_enemies.gd).

  • Step 7: Commit
Terminal window
git add sim/enemy_pool.gd sim/sim.gd tests/test_new_enemies.gd
git commit -m "feat(sim): wire TYPE_CENTURION into EnemyPool + Sim._build_enemy_types"

Task 3: Spawn eligibility — AreaDefs, SpawnTable, SpawnDirector

Section titled “Task 3: Spawn eligibility — AreaDefs, SpawnTable, SpawnDirector”

Files:

  • Modify: sim/area_defs.gd:23-57 (add Centurion to home/fire/void_dim/light)
  • Modify: sim/spawn_table.gd:19-25,54,56 (add to UNLOCK_ORDER + the 150s/180s+ table() entries)
  • Modify: sim/spawn_director.gd:79-93 (add to the late-game else bucket only)
  • Test: tests/test_dimensions.gd, tests/test_spawn_table.gd, tests/test_new_enemies.gd

Interfaces:

  • Consumes: EnemyPool.TYPE_CENTURION from Task 2.

  • Produces: Centurion becomes spawnable (and per-dimension reflavored) via every real spawn path in the game — nothing downstream depends on new interfaces here, this is the last piece needed for Centurion to appear in actual play.

  • Step 1: Write the failing tests

Append to tests/test_dimensions.gd (anywhere after the existing dimension-reflavor tests, e.g. right after test_spawn_one_in_void_dim_reflavors_orbiter_to_void):

func test_centurion_is_in_every_dimensions_roster() -> void:
for dim in [AreaDefs.HOME, AreaDefs.FIRE, AreaDefs.VOID_DIM, AreaDefs.LIGHT]:
assert_true(AreaDefs.enemy_types_for(dim).has(EnemyPool.TYPE_CENTURION),
"%s must include Centurion in its enemy_types roster" % dim)
func test_spawn_one_in_fire_reflavors_centurion_to_fire() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var sim := Sim.new(1234, content)
sim.enter_area(AreaDefs.FIRE)
sim._spawn_one(EnemyPool.TYPE_CENTURION, Vector2.ZERO)
assert_eq(sim.enemies.count, 1)
assert_eq(sim.enemies.base_element[0], content.element_index("fire"),
"Fire reflavors Centurion from native-aether to fire")
func test_spawn_one_in_void_dim_reflavors_centurion_to_void() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var sim := Sim.new(1234, content)
sim.enter_area(AreaDefs.VOID_DIM)
sim._spawn_one(EnemyPool.TYPE_CENTURION, Vector2.ZERO)
assert_eq(sim.enemies.count, 1)
assert_eq(sim.enemies.base_element[0], content.element_index("void"),
"Void reflavors Centurion from native-aether to void")
func test_spawn_one_in_light_reflavors_centurion_to_light() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var sim := Sim.new(1234, content)
sim.enter_area(AreaDefs.LIGHT)
sim._spawn_one(EnemyPool.TYPE_CENTURION, Vector2.ZERO)
assert_eq(sim.enemies.count, 1)
assert_eq(sim.enemies.base_element[0], content.element_index("light"),
"Light reflavors Centurion from native-aether to light")
func test_spawn_one_in_home_keeps_centurion_native_aether() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var sim := Sim.new(1234, content)
sim.enter_area(AreaDefs.HOME)
sim._spawn_one(EnemyPool.TYPE_CENTURION, Vector2.ZERO)
assert_eq(sim.enemies.count, 1)
assert_eq(sim.enemies.base_element[0], content.element_index("aether"),
"Home has no override for Centurion — it keeps its own native aether element")

Append to tests/test_spawn_table.gd (after the existing “Orbiter + Lancer”-style wave tests, near test_each_wave_unlocks_one_more_type):

func test_centurion_debuts_last_and_only_late_game() -> void:
# Centurion is the 14th (last) UNLOCK_ORDER entry -> debuts at wave 15.
assert_false(SpawnTable.types_unlocked(14).has(EnemyPool.TYPE_CENTURION), "not unlocked before wave 15")
assert_true(SpawnTable.types_unlocked(15).has(EnemyPool.TYPE_CENTURION), "unlocked at wave 15")
func test_centurion_absent_from_early_time_buckets() -> void:
for t in [0.0, 30.0, 60.0, 90.0, 120.0]:
assert_false(SpawnTable.weights_at(t).has(EnemyPool.TYPE_CENTURION),
"centurion must not appear in the time-weighted table before 150s (t=%s)" % t)
func test_centurion_present_in_late_time_buckets() -> void:
assert_true(SpawnTable.weights_at(150.0).has(EnemyPool.TYPE_CENTURION))
assert_true(SpawnTable.weights_at(99999.0).has(EnemyPool.TYPE_CENTURION), "present in the endless tail")

Append to the Centurion section of tests/test_new_enemies.gd:

func test_centurion_not_in_early_swarm_burst_spawn() -> void:
# SpawnDirector.pick_type feeds the rare swarm-burst event; Centurion must only appear
# in its late-game bucket, matching every other late-game-only type (e.g. bomber).
var rng := SeededRng.new(321)
var found := false
for _n in range(500):
var tid := SpawnDirector.new().pick_type(60.0, rng)
if tid == EnemyPool.TYPE_CENTURION:
found = true
break
assert_false(found, "centurion does not spawn before 120s in the swarm-burst path")
func test_centurion_appears_after_120s_in_swarm_burst_pool() -> void:
var rng := SeededRng.new(654)
var found := false
for _n in range(1000):
var tid := SpawnDirector.new().pick_type(150.0, rng)
if tid == EnemyPool.TYPE_CENTURION:
found = true
break
assert_true(found, "centurion can appear in the swarm-burst pool once past 120s")
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_dimensions -gexit Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_spawn_table -gexit Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_new_enemies -gexit Expected: every new test FAILS except test_spawn_one_in_home_keeps_centurion_native_aether — Home never gets an enemy_elements override for Centurion in this plan (that’s the intended no-op case), so it already passes before Step 4 and stays passing after; it’s a regression guard, not a red/green gate. Every other new test depends on an edit made in Step 4 or 5 below and must be red right now.

  • Step 3: Wire sim/area_defs.gd

Change the home entry’s enemy_types:

"boss": "warden",
"enemy_types": [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_SHOOTER, EnemyPool.TYPE_ELITE],

to:

"boss": "warden",
"enemy_types": [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_SHOOTER, EnemyPool.TYPE_ELITE, EnemyPool.TYPE_CENTURION],

Change the fire entry:

"element": "fire", "boss": "warden",
"enemy_types": [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_ELITE, EnemyPool.TYPE_SHOOTER, EnemyPool.TYPE_LANCER],
"enemy_elements": {EnemyPool.TYPE_ELITE: "fire", EnemyPool.TYPE_SHOOTER: "fire", EnemyPool.TYPE_LANCER: "fire"},

to:

"element": "fire", "boss": "warden",
"enemy_types": [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_ELITE, EnemyPool.TYPE_SHOOTER, EnemyPool.TYPE_LANCER, EnemyPool.TYPE_CENTURION],
"enemy_elements": {EnemyPool.TYPE_ELITE: "fire", EnemyPool.TYPE_SHOOTER: "fire", EnemyPool.TYPE_LANCER: "fire", EnemyPool.TYPE_CENTURION: "fire"},

Change the void_dim entry:

"element": "void", "boss": "graviton",
"enemy_types": [EnemyPool.TYPE_ELITE, EnemyPool.TYPE_ORBITER, EnemyPool.TYPE_GHOST, EnemyPool.TYPE_SPIDER],
"enemy_elements": {EnemyPool.TYPE_ORBITER: "void", EnemyPool.TYPE_SPIDER: "void"},

to:

"element": "void", "boss": "graviton",
"enemy_types": [EnemyPool.TYPE_ELITE, EnemyPool.TYPE_ORBITER, EnemyPool.TYPE_GHOST, EnemyPool.TYPE_SPIDER, EnemyPool.TYPE_CENTURION],
"enemy_elements": {EnemyPool.TYPE_ORBITER: "void", EnemyPool.TYPE_SPIDER: "void", EnemyPool.TYPE_CENTURION: "void"},

Change the light entry:

"element": "light", "boss": "eye",
"enemy_types": [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_LANCER, EnemyPool.TYPE_ORBITER, EnemyPool.TYPE_ACCUMULATOR],
"enemy_elements": {EnemyPool.TYPE_SWARMER: "light", EnemyPool.TYPE_ORBITER: "light", EnemyPool.TYPE_ACCUMULATOR: "light"},

to:

"element": "light", "boss": "eye",
"enemy_types": [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_LANCER, EnemyPool.TYPE_ORBITER, EnemyPool.TYPE_ACCUMULATOR, EnemyPool.TYPE_CENTURION],
"enemy_elements": {EnemyPool.TYPE_SWARMER: "light", EnemyPool.TYPE_ORBITER: "light", EnemyPool.TYPE_ACCUMULATOR: "light", EnemyPool.TYPE_CENTURION: "light"},
  • Step 4: Wire sim/spawn_table.gd

Change UNLOCK_ORDER:

const UNLOCK_ORDER: Array = [
EnemyPool.TYPE_SPIDER, # first unlock → spider appears at wave 2
EnemyPool.TYPE_SHOOTER, EnemyPool.TYPE_ELITE, EnemyPool.TYPE_SPLITTER,
EnemyPool.TYPE_GHOST, EnemyPool.TYPE_SCATTERER, EnemyPool.TYPE_ORBITER,
EnemyPool.TYPE_SKIRMISHER, EnemyPool.TYPE_LANCER, EnemyPool.TYPE_BRUTE,
EnemyPool.TYPE_BOMBER, EnemyPool.TYPE_ACCUMULATOR, EnemyPool.TYPE_TANK,
]

to:

const UNLOCK_ORDER: Array = [
EnemyPool.TYPE_SPIDER, # first unlock → spider appears at wave 2
EnemyPool.TYPE_SHOOTER, EnemyPool.TYPE_ELITE, EnemyPool.TYPE_SPLITTER,
EnemyPool.TYPE_GHOST, EnemyPool.TYPE_SCATTERER, EnemyPool.TYPE_ORBITER,
EnemyPool.TYPE_SKIRMISHER, EnemyPool.TYPE_LANCER, EnemyPool.TYPE_BRUTE,
EnemyPool.TYPE_BOMBER, EnemyPool.TYPE_ACCUMULATOR, EnemyPool.TYPE_TANK,
EnemyPool.TYPE_CENTURION, # last unlock → debuts wave 15, matching its late-game table weight
]

Change the 150s entry in table():

# 150s
{EnemyPool.TYPE_SWARMER: 0.22, EnemyPool.TYPE_SHOOTER: 0.12, EnemyPool.TYPE_SCATTERER: 0.1, EnemyPool.TYPE_GHOST: 0.1, EnemyPool.TYPE_ORBITER: 0.1, EnemyPool.TYPE_LANCER: 0.08, EnemyPool.TYPE_BOMBER: 0.05, EnemyPool.TYPE_BRUTE: 0.05, EnemyPool.TYPE_ACCUMULATOR: 0.03, EnemyPool.TYPE_TANK: 0.05, EnemyPool.TYPE_ELITE: 0.1},

to:

# 150s
{EnemyPool.TYPE_SWARMER: 0.17, EnemyPool.TYPE_SHOOTER: 0.12, EnemyPool.TYPE_SCATTERER: 0.1, EnemyPool.TYPE_GHOST: 0.1, EnemyPool.TYPE_ORBITER: 0.1, EnemyPool.TYPE_LANCER: 0.08, EnemyPool.TYPE_BOMBER: 0.05, EnemyPool.TYPE_BRUTE: 0.05, EnemyPool.TYPE_ACCUMULATOR: 0.03, EnemyPool.TYPE_TANK: 0.05, EnemyPool.TYPE_ELITE: 0.1, EnemyPool.TYPE_CENTURION: 0.05},

Change the 180s+ (endless tail) entry:

# 180s+ (endless tail)
{EnemyPool.TYPE_SWARMER: 0.18, EnemyPool.TYPE_SHOOTER: 0.12, EnemyPool.TYPE_SCATTERER: 0.1, EnemyPool.TYPE_GHOST: 0.1, EnemyPool.TYPE_ORBITER: 0.1, EnemyPool.TYPE_LANCER: 0.08, EnemyPool.TYPE_BOMBER: 0.07, EnemyPool.TYPE_BRUTE: 0.05, EnemyPool.TYPE_ACCUMULATOR: 0.05, EnemyPool.TYPE_TANK: 0.05, EnemyPool.TYPE_ELITE: 0.1},

to:

# 180s+ (endless tail)
{EnemyPool.TYPE_SWARMER: 0.13, EnemyPool.TYPE_SHOOTER: 0.12, EnemyPool.TYPE_SCATTERER: 0.1, EnemyPool.TYPE_GHOST: 0.1, EnemyPool.TYPE_ORBITER: 0.1, EnemyPool.TYPE_LANCER: 0.08, EnemyPool.TYPE_BOMBER: 0.07, EnemyPool.TYPE_BRUTE: 0.05, EnemyPool.TYPE_ACCUMULATOR: 0.05, EnemyPool.TYPE_TANK: 0.05, EnemyPool.TYPE_ELITE: 0.1, EnemyPool.TYPE_CENTURION: 0.05},
  • Step 5: Wire sim/spawn_director.gd

In the late-game else bucket of pick_type (run_time >= 120.0), change:

elif r < 0.83: return EnemyPool.TYPE_ACCUMULATOR # priority target — grows without kills
else: return EnemyPool.TYPE_SWARMER

to:

elif r < 0.83: return EnemyPool.TYPE_ACCUMULATOR # priority target — grows without kills
elif r < 0.87: return EnemyPool.TYPE_CENTURION # armored ranged sentry, rare late-game presence
else: return EnemyPool.TYPE_SWARMER

(This is the ONLY bucket touched in this file — the <20.0, <30.0, <50.0, and <120.0 buckets are left untouched, which is why the determinism baseline is unaffected: the <20.0 branch, the only one exercised inside the 10-second baseline window, doesn’t depend on r at all.)

  • Step 6: Run tests to verify they pass

Run each of the three suites again:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_dimensions -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_spawn_table -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_new_enemies -gexit

Expected: PASS on all of them, including test_every_authored_vector_sums_to_one (pre-existing — confirms the 150s/180s+ edits still sum to 1.0).

  • Step 7: Commit
Terminal window
git add sim/area_defs.gd sim/spawn_table.gd sim/spawn_director.gd tests/test_dimensions.gd tests/test_spawn_table.gd tests/test_new_enemies.gd
git commit -m "feat(spawn): make Centurion spawnable + per-dimension reflavored in Home/Fire/Void/Light"

Task 4: Render — SHAPE_ROSETTE + color LUT

Section titled “Task 4: Render — SHAPE_ROSETTE + color LUT”

Files:

  • Modify: render/archetype_renderer.gd:9-47,169-237 (new shape constant + mesh builder)
  • Modify: main.gd:1341-1369 (color name mapping + LUT resize)
  • Test: tests/test_archetype_renderer.gd, tests/test_main.gd

Interfaces:

  • Consumes: EnemyPool.TYPE_CENTURION from Task 2.

  • Produces: ArchetypeRenderer.shape_for(EnemyPool.TYPE_CENTURION) returns a shape with no sprite texture, distinct from every other type; main._base_color_for_type(TYPE_CENTURION, el) follows the same generic per-element path as any ordinary type.

  • Step 1: Write the failing tests

Append to tests/test_archetype_renderer.gd:

func test_centurion_shape_has_no_sprite_texture() -> void:
var r := ArchetypeRenderer.new()
add_child_autofree(r)
await get_tree().process_frame
var shape := r.shape_for(EnemyPool.TYPE_CENTURION)
assert_null(r._meshes[shape].texture, "Centurion is a procedural vector shape, not a sprite")
func test_centurion_shape_distinct_from_swarmer() -> void:
# Before wiring, an out-of-range type_id falls back to SHAPE_TRIANGLE (shape_for's documented
# fallback) -- the SAME shape Swarmer uses. Comparing against Shooter/Elite would pass by
# accident even unwired (they already differ from the triangle fallback); comparing against
# Swarmer specifically is what actually goes red before Step 3 and green after.
var r := ArchetypeRenderer.new()
assert_ne(r.shape_for(EnemyPool.TYPE_CENTURION), r.shape_for(EnemyPool.TYPE_SWARMER))

Append to tests/test_main.gd (near the existing Warden color tests):

func test_centurion_color_follows_the_generic_per_element_path() -> void:
# Centurion must NOT be special-cased in _base_color_for_type -- it should take the exact
# same generic per-element branch as any ordinary enemy (e.g. Shooter), proven by comparing
# the two directly rather than hardcoding an expected Color in this test.
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("crystals")
var fire_idx := m.sim.content.element_index("fire")
assert_eq(
m._base_color_for_type(EnemyPool.TYPE_CENTURION, fire_idx),
m._base_color_for_type(EnemyPool.TYPE_SHOOTER, fire_idx),
"Centurion takes the same generic per-element colour path as an ordinary enemy type")
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_archetype_renderer -gexit Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_main -gexit Expected: test_centurion_shape_distinct_from_swarmer FAILS — shape_for(TYPE_CENTURION) currently falls back to SHAPE_TRIANGLE (out of _TYPE_SHAPE’s current bounds), the same shape Swarmer uses, so they’re equal and assert_ne fails. The other two new tests (test_centurion_shape_has_no_sprite_texture and test_centurion_color_follows_the_generic_per_element_path) are expected to PASS already at this point — they’re permanent regression guards (the triangle fallback has no texture either, and _base_color_for_type was never given a TYPE_CENTURION special case to begin with), not red/green gates for this specific task. Step 3 is still required regardless, since a shared fallback shape with Swarmer is not an acceptable final look for Centurion even though only one test catches it.

  • Step 3: Add SHAPE_ROSETTE to render/archetype_renderer.gd

Update the header comment block:

# SHAPE_ORBITER_SPRITE = 17 — orbiter (real sprite art, own bucket — see _SPRITE_FOR_SHAPE)
# SHAPE_WARDEN_SPRITE = 18 — warden (real sprite art, own bucket — see _SPRITE_FOR_SHAPE)

to:

# SHAPE_ORBITER_SPRITE = 17 — orbiter (real sprite art, own bucket — see _SPRITE_FOR_SHAPE)
# SHAPE_WARDEN_SPRITE = 18 — warden (real sprite art, own bucket — see _SPRITE_FOR_SHAPE)
# SHAPE_ROSETTE = 19 — centurion (scalloped segmented disc — procedural vector, no sprite;
# Nova Drift-style multiply-tint enemy, see design spec 2026-07-06)

Change the shape constants:

const SHAPE_WARDEN_SPRITE := 18 # warden — real sprite art (same Recraft/glow-bake pipeline as the
# orbiter), own bucket so it never shares a MultiMesh with SHAPE_CIRCLE
# (still used by Boss2/FunZo/Eye)
const SHAPE_COUNT := 19

to:

const SHAPE_WARDEN_SPRITE := 18 # warden — real sprite art (same Recraft/glow-bake pipeline as the
# orbiter), own bucket so it never shares a MultiMesh with SHAPE_CIRCLE
# (still used by Boss2/FunZo/Eye)
const SHAPE_ROSETTE := 19 # centurion — scalloped segmented disc, pure procedural vector geometry
const SHAPE_COUNT := 20

Change the _TYPE_SHAPE array’s comment header and tail:

# TYPE_ZAPPER=10 TYPE_SCATTERER=11 TYPE_BOMBER=12 TYPE_ORBITER=13 TYPE_LANCER=14 TYPE_BOSS2=15 TYPE_GHOST=16
# TYPE_ACCUMULATOR=17 TYPE_TANK_MISSILE=18 TYPE_FUNZO=19 TYPE_GRAVITON=20 TYPE_EYE=21
const _TYPE_SHAPE: Array[int] = [

to:

# TYPE_ZAPPER=10 TYPE_SCATTERER=11 TYPE_BOMBER=12 TYPE_ORBITER=13 TYPE_LANCER=14 TYPE_BOSS2=15 TYPE_GHOST=16
# TYPE_ACCUMULATOR=17 TYPE_TANK_MISSILE=18 TYPE_FUNZO=19 TYPE_GRAVITON=20 TYPE_EYE=21 TYPE_CUSTOM=22
# TYPE_CENTURION=23
const _TYPE_SHAPE: Array[int] = [

and its last two lines:

SHAPE_RING, # 20 GRAVITON — gravity/darkness boss (ring: event horizon aesthetic; body drawn by GravitonRenderer)
SHAPE_CIRCLE, # 21 EYE — predictive-sight boss (circle; real body drawn by EyeRenderer)
]

to:

SHAPE_RING, # 20 GRAVITON — gravity/darkness boss (ring: event horizon aesthetic; body drawn by GravitonRenderer)
SHAPE_CIRCLE, # 21 EYE — predictive-sight boss (circle; real body drawn by EyeRenderer)
SHAPE_TRIANGLE, # 22 CUSTOM — real shape comes from its own shape_id override (see shape_for);
# this entry is just a defensive fallback if ever queried without one
SHAPE_ROSETTE, # 23 CENTURION — scalloped segmented disc, procedural vector (no sprite)
]

Add the _mesh_for_shape match arm. Change:

SHAPE_MISSILE:
return _polygon_mesh(_missile_pts(radius))
SHAPE_ORBITER_SPRITE:
return _sprite_quad(radius)
SHAPE_WARDEN_SPRITE:
return _sprite_quad(radius)
_:

to:

SHAPE_MISSILE:
return _polygon_mesh(_missile_pts(radius))
SHAPE_ORBITER_SPRITE:
return _sprite_quad(radius)
SHAPE_WARDEN_SPRITE:
return _sprite_quad(radius)
SHAPE_ROSETTE:
# Reuses the same generalized star-polygon builder SHAPE_STAR uses, just with far more
# points and a much shallower valley -- reads as scalloped segmented plates rather than
# a sharp star. No sprite/texture at all: this is the first shape in the renderer built
# to be genuinely colorless, so the per-instance multiply-tint (see sync()) is fully
# clean. Point count/valley depth are tunable -- iterate visually against the concept
# reference in concept-art/gemini/14_enemy-centurion-concept.png.
return _polygon_mesh(_star_pts(12, radius, radius * 0.80))
_:
  • Step 4: Add the color mapping in main.gd

Change:

var names := {
EnemyPool.TYPE_SWARMER: "swarmer",
EnemyPool.TYPE_TANK: "tank",
EnemyPool.TYPE_SHOOTER: "shooter",
EnemyPool.TYPE_SPLITTER: "splitter",
EnemyPool.TYPE_ELITE: "elite",
EnemyPool.TYPE_SPIDER: "spider",
EnemyPool.TYPE_SKIRMISHER: "skirmisher",
EnemyPool.TYPE_BRUTE: "brute",
EnemyPool.TYPE_RUSHER: "rusher",
EnemyPool.TYPE_ZAPPER: "zapper",
EnemyPool.TYPE_SCATTERER: "scatterer",
EnemyPool.TYPE_BOMBER: "bomber",
EnemyPool.TYPE_ORBITER: "orbiter",
EnemyPool.TYPE_LANCER: "lancer",
EnemyPool.TYPE_GHOST: "ghost",
EnemyPool.TYPE_ACCUMULATOR: "accumulator",
EnemyPool.TYPE_TANK_MISSILE: "tank_missile",
EnemyPool.TYPE_FUNZO: "funzo",
EnemyPool.TYPE_GRAVITON: "graviton",
EnemyPool.TYPE_EYE: "eye",
}
_enemy_type_colors = PackedColorArray()
# Index by type id (ids are non-contiguous — boss=6/boss2=15 have no bible entry),
# so the array must be sized to the MAX id, not the entry count.
_enemy_type_colors.resize(EnemyPool.TYPE_EYE + 1)

to:

var names := {
EnemyPool.TYPE_SWARMER: "swarmer",
EnemyPool.TYPE_TANK: "tank",
EnemyPool.TYPE_SHOOTER: "shooter",
EnemyPool.TYPE_SPLITTER: "splitter",
EnemyPool.TYPE_ELITE: "elite",
EnemyPool.TYPE_SPIDER: "spider",
EnemyPool.TYPE_SKIRMISHER: "skirmisher",
EnemyPool.TYPE_BRUTE: "brute",
EnemyPool.TYPE_RUSHER: "rusher",
EnemyPool.TYPE_ZAPPER: "zapper",
EnemyPool.TYPE_SCATTERER: "scatterer",
EnemyPool.TYPE_BOMBER: "bomber",
EnemyPool.TYPE_ORBITER: "orbiter",
EnemyPool.TYPE_LANCER: "lancer",
EnemyPool.TYPE_GHOST: "ghost",
EnemyPool.TYPE_ACCUMULATOR: "accumulator",
EnemyPool.TYPE_TANK_MISSILE: "tank_missile",
EnemyPool.TYPE_FUNZO: "funzo",
EnemyPool.TYPE_GRAVITON: "graviton",
EnemyPool.TYPE_EYE: "eye",
EnemyPool.TYPE_CENTURION: "centurion",
}
_enemy_type_colors = PackedColorArray()
# Index by type id (ids are non-contiguous — boss=6/boss2=15 have no bible entry),
# so the array must be sized to the MAX id, not the entry count.
_enemy_type_colors.resize(EnemyPool.TYPE_CENTURION + 1)
  • Step 5: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_archetype_renderer -gexit Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_main -gexit Expected: PASS.

  • Step 6: Commit
Terminal window
git add render/archetype_renderer.gd main.gd tests/test_archetype_renderer.gd tests/test_main.gd
git commit -m "feat(render): add SHAPE_ROSETTE — a genuinely colorless procedural shape for Centurion"

Task 5: Attack behavior — reuse the Shooter’s aimed-fire pattern

Section titled “Task 5: Attack behavior — reuse the Shooter’s aimed-fire pattern”

Files:

  • Modify: sim/enemy_attacks.gd:101 (extend update_shooters’s type check)
  • Test: tests/test_shooter_enemy.gd

Interfaces:

  • Consumes: EnemyPool.TYPE_CENTURION from Task 2.

  • Produces: a Centurion enemy fires an aimed projectile at the nearest pilot on the same cadence as a Shooter (EnemyAttacks.SHOOTER_FIRE_INTERVAL), via Sim.enemy_attacks.update_shooters.

  • Step 1: Write the failing test

Append to tests/test_shooter_enemy.gd:

func test_centurion_fires_after_delay_reusing_shooters_attack() -> void:
var sim := _shooter_sim()
var ci := sim.enemies.add(Vector2(300, 0), Vector2.ZERO, 14.0, 14.0,
2.0, 45.0, 10.0, 4.0, EnemyPool.TYPE_CENTURION)
sim._shooter_timers[sim.enemies.entity_id[ci]] = EnemyAttacks.SHOOTER_FIRE_INTERVAL + 0.01
sim.enemy_attacks.update_shooters(sim, Sim_Const.DT)
assert_gt(sim.enemy_proj.count, 0, "Centurion fired a projectile at the player, reusing the Shooter's attack loop")
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_shooter_enemy -gexit Expected: FAIL — update_shooters only matches TYPE_SHOOTER today, so a Centurion’s ready timer is silently ignored and no projectile fires (enemy_proj.count stays 0).

  • Step 3: Extend the type check in sim/enemy_attacks.gd

Change:

for i in range(sim.enemies.count):
if sim.enemies.type_id[i] != EnemyPool.TYPE_SHOOTER:
continue

to:

for i in range(sim.enemies.count):
if sim.enemies.type_id[i] != EnemyPool.TYPE_SHOOTER and sim.enemies.type_id[i] != EnemyPool.TYPE_CENTURION:
continue
  • Step 4: Run test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_shooter_enemy -gexit Expected: PASS.

  • Step 5: Commit
Terminal window
git add sim/enemy_attacks.gd tests/test_shooter_enemy.gd
git commit -m "feat(sim): Centurion reuses the Shooter's aimed-fire attack loop"

Task 6: Full suite + determinism re-verification

Section titled “Task 6: Full suite + determinism re-verification”

Files: none modified — this is a verification-only checkpoint task.

Interfaces:

  • Consumes: everything from Tasks 1-5.

  • Produces: confirmation the pinned determinism baseline held, and that the full suite is green, before any rendering/gameplay claim in this feature is trusted.

  • Step 1: Fresh import (only if this plan was executed in a new worktree)

Run: godot --headless --path . --import Expected: exits cleanly. Skip this step if working in the main checkout that’s already imported.

  • Step 2: Run the full GUT suite

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: the printed “Run Summary” (which appears BEFORE the known headless-teardown SIGABRT, exit 134 — judge by the summary text, not the process exit code, per this repo’s documented Godot gotcha) shows 0 failures and a script count matching tests/test_*.gd’s file count plus the new files touched in this plan (none added — all tests were appended to existing files).

  • Step 3: Run the test-count guard

Run: scripts/check-test-count.sh Expected: passes (confirms GUT didn’t silently drop any script from a stale class cache). If this script aborts with no output, first check the raw -gdir run’s Scripts line directly per this repo’s documented gotcha (a real test failure masks the count check, it doesn’t mean the guard itself broke).

  • Step 4: Confirm the determinism baseline explicitly

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_determinism_checksum -gexit Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_determinism_crystals -gexit Expected: both PASS, asserting snapshot_string().hash() == 4217109746 and state_checksum() == 2666143677 unchanged. If either FAILS: do not “fix” it by editing the assertion — that means one of Tasks 3/5’s placements leaked into the 10-second baseline window. Re-check which bucket/table entry was touched (only <120.0’s else branch in spawn_director.gd, and only the 150s/180s+ entries in spawn_table.gd, should ever have changed) and correct the placement rather than re-pinning.

  • Step 5: No commit — this task verifies prior commits; nothing new to check in.

Task 7: Visual verification harness + docs update

Section titled “Task 7: Visual verification harness + docs update”

Files:

  • Create: tools/glow_bake/live_check_centurion.gd
  • Create: tools/glow_bake/live_check_centurion.tscn
  • Modify: docs/architecture/enemies-bosses-survival.md (append a new section)

Interfaces:

  • Consumes: everything from Tasks 1-5 (a fully-wired, spawnable, tinted Centurion).

  • Produces: 4 PNG screenshots (tools/glow_bake/out/centurion_home.png, _fire.png, _void.png, _light.png) for visual comparison against concept-art/gemini/14_enemy-centurion-concept.png and against each other — this is the step that actually proves the “one shape, many colors” payoff, which no automated test can judge.

  • Step 1: Write the harness script

Create tools/glow_bake/live_check_centurion.gd:

extends Node
# DEV ONLY -- boots the REAL game, forces each Elemental Dimension in turn, and dev-spawns a
# Centurion to visually confirm the Nova Drift multiply-tint technique: ONE procedural shape
# (SHAPE_ROSETTE, genuinely colorless) rendering convincingly in 4 different colors depending on
# which dimension it spawned in -- no per-dimension art, no special-case colour code.
# godot --path . --rendering-method mobile res://tools/glow_bake/live_check_centurion.tscn
const OUT_DIR := "res://tools/glow_bake/out"
const AREAS := [
{"id": "home", "file": "centurion_home.png"},
{"id": "fire", "file": "centurion_fire.png"},
{"id": "void_dim", "file": "centurion_void.png"},
{"id": "light", "file": "centurion_light.png"},
]
var vp: SubViewport
var main
func _ready() -> void:
vp = SubViewport.new()
vp.size = Vector2i(1920, 1080)
vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS
vp.handle_input_locally = false
add_child(vp)
main = preload("res://main.tscn").instantiate()
vp.add_child(main)
await get_tree().process_frame
await get_tree().process_frame
main._on_mode_chosen("crystals")
await get_tree().process_frame
var s = main.sim
s.player.dev_invuln = true
s.dev_suppress_spawns = true
main.camera.zoom = Vector2(2.2, 2.2)
var dir := ProjectSettings.globalize_path(OUT_DIR)
DirAccess.make_dir_recursive_absolute(dir)
for area in AREAS:
s.enter_area(area["id"])
s.dev_clear_enemies()
s._spawn_one(EnemyPool.TYPE_CENTURION, s.player.pos + Vector2(200, 0))
for _i in range(10):
await get_tree().process_frame
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var img := vp.get_texture().get_image()
var path: String = dir.path_join(area["file"])
img.save_png(path)
print("CENTURION_LIVE_SAVED %s %s" % [path, img.get_size()])
get_tree().quit()
  • Step 2: Create the matching scene

Create tools/glow_bake/live_check_centurion.tscn (a bare scene with the script attached as its root node — follow the exact structure of the existing tools/glow_bake/live_check_warden.tscn in this same directory: open that file, note its root node type, and create this one identically with the script path swapped to res://tools/glow_bake/live_check_centurion.gd).

  • Step 3: Run the harness and inspect the output

Run: godot --path . --rendering-method mobile res://tools/glow_bake/live_check_centurion.tscn Expected: prints 4 CENTURION_LIVE_SAVED lines, one per area, and 4 PNGs appear under tools/glow_bake/out/.

Read each of the 4 PNGs plus concept-art/gemini/14_enemy-centurion-concept.png and confirm:

  • The silhouette reads as a scalloped/segmented disc, recognizably related to the reference art.
  • The 4 area captures are visibly DIFFERENT colors from each other (home = its own native aether tint, fire = orange/red, void = purple, light = gold/white) — this is the actual pass/fail criterion for the whole feature, not any automated assertion.
  • None of the 4 is pixel-identical to another (a capture that “looks plausible” is not proof by itself — this repo has been burned by that before; a direct visual diff is the real check).

If the shape doesn’t read as segmented enough, or a color doesn’t land clearly, adjust the _star_pts(12, radius, radius * 0.80) parameters in Task 4 Step 3 (try more/fewer points or a shallower/deeper valley) and re-run this harness — this is the intended iteration loop the design spec flagged as “tune by eye,” not a fixed number to trust blindly.

  • Step 4: Update the architecture doc

Append a new section to the end of docs/architecture/enemies-bosses-survival.md:

## Centurion — Nova Drift-style multiply-tint enemy (DONE, pre-deploy)
Spec `docs/superpowers/specs/2026-07-06-centurion-enemy-design.md`, plan
`docs/superpowers/plans/2026-07-06-centurion-enemy.md`. First enemy built on genuinely colorless
base art, adapting Nova Drift's custom-skins technique (player color applied multiplicatively onto
pure-white art) — proves it out with a procedural VECTOR shape instead of a sprite, so there's no
texture to keep neutral.
- **`SHAPE_ROSETTE` (`render/archetype_renderer.gd`):** a scalloped/segmented disc built by
reusing the existing generalized `_star_pts(points, outer, inner)` function (the same one
`SHAPE_STAR` uses), just with far more points and a much shallower valley. **Reusable pattern for
future enemies:** any new procedural shape that should support clean per-instance multiply-tint
should stay off the sprite-quad path entirely (no `_SPRITE_FOR_SHAPE` entry) — a pure vector
polygon has no baked-in hue to fight, unlike every current sprite (Orbiter/Warden/Spider/Ghost),
which are all pre-colored concept art and only support a 1-2-state color swap, not arbitrary tint.
- **Color is free, spawn eligibility is not.** `main.gd`'s `_base_color_for_type` already had a
generic per-element branch every ordinary type falls through — Centurion needed zero new color
code. But **spawn eligibility is a SEPARATE gate**, discovered mid-plan: `sim/area_defs.gd`
curates a per-area `enemy_types` allow-list that every spawn path (`SpawnTable.pick`'s `allowed`
param, `Sim._remap_to_dimension`) filters against BEFORE color is ever considered — and `home`
counts as a curated area too (it has a `boss` field, same as Fire/Void/Light). A new enemy type
absent from an area's roster simply never spawns there, silently, regardless of every other wiring
step. **Any future enemy meant to appear broadly must be added to `area_defs.gd`'s `enemy_types`
explicitly — it is not automatic.**
- **Two independent spawn paths, not one:** `SpawnTable` (`sim/spawn_table.gd`) drives the primary
per-wave spawner (`Sim._spawn_wave`); `SpawnDirector.pick_type` (`sim/spawn_director.gd`) only
feeds the rare one-time swarm-burst event. A new type needs wiring into both to appear everywhere
a similar existing type does.
- Determinism: gated to the latest time-bucket/wave-unlock/RNG-bucket in all three spawn-gating
files, verified unchanged (`4217109746`/`2666143677`) rather than assumed.
  • Step 5: Commit
Terminal window
git add tools/glow_bake/live_check_centurion.gd tools/glow_bake/live_check_centurion.tscn docs/architecture/enemies-bosses-survival.md
git commit -m "docs+tools: Centurion visual verification harness + architecture doc update"

Owed after this plan (not blocking merge, per this repo’s convention for new content)

Section titled “Owed after this plan (not blocking merge, per this repo’s convention for new content)”
  • On-device playtest (Mac export at minimum) — is the Centurion’s silhouette readable at swarm scale and at TV viewing distance once deployed? Not testable headlessly.
  • Balance pass on the Task 1 stats (hp/speed/armor/xp) — flagged as tunable in the design spec, not a conclusion.
  • _star_pts(12, radius, radius * 0.80)’s exact point count/valley depth may need retuning after Task 7’s visual check — flagged inline in that task.