Centurion Enemy Implementation Plan
Centurion Enemy Implementation Plan
Section titled “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.
Global Constraints
Section titled “Global Constraints”TYPE_CENTURION := 23(next free id afterTYPE_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.jsonis hand-maintained and has drifted ahead oftools/design-bible/src/seed.js— never regenerate it from that script; only hand-edit.- All indentation in
.gdand.jsonfiles 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 todata.enemiesarray) - Modify:
data/bestiary.json(append toentriesobject) - Test:
tests/test_new_enemies.gd(append a new section)
Interfaces:
-
Produces: a bible entry queryable via
ContentDB.enemy("centurion")returning aDictionarywith keysid, 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
git add data/bible.json data/bestiary.json tests/test_new_enemies.gdgit commit -m "feat(content): add Centurion enemy bible + bestiary entries"Task 2: EnemyPool type id + Sim wiring
Section titled “Task 2: EnemyPool type id + Sim wiring”Files:
- Modify:
sim/enemy_pool.gd:26,29-32(addTYPE_CENTURION, extendTYPE_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-emptyDictionarywith 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 typeconst 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_typesinsim/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_WALKDo 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
git add sim/enemy_pool.gd sim/sim.gd tests/test_new_enemies.gdgit 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 tohome/fire/void_dim/light) - Modify:
sim/spawn_table.gd:19-25,54,56(add toUNLOCK_ORDER+ the 150s/180s+table()entries) - Modify:
sim/spawn_director.gd:79-93(add to the late-gameelsebucket only) - Test:
tests/test_dimensions.gd,tests/test_spawn_table.gd,tests/test_new_enemies.gd
Interfaces:
-
Consumes:
EnemyPool.TYPE_CENTURIONfrom 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_SWARMERto:
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:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_dimensions -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_spawn_table -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_new_enemies -gexitExpected: 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
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.gdgit 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_CENTURIONfrom 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_ROSETTEtorender/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 := 19to:
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 geometryconst SHAPE_COUNT := 20Change 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=21const _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=23const _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
git add render/archetype_renderer.gd main.gd tests/test_archetype_renderer.gd tests/test_main.gdgit 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(extendupdate_shooters’s type check) - Test:
tests/test_shooter_enemy.gd
Interfaces:
-
Consumes:
EnemyPool.TYPE_CENTURIONfrom 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), viaSim.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: continueto:
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
git add sim/enemy_attacks.gd tests/test_shooter_enemy.gdgit 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 againstconcept-art/gemini/14_enemy-centurion-concept.pngand 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: SubViewportvar 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 colorlessbase art, adapting Nova Drift's custom-skins technique (player color applied multiplicatively ontopure-white art) — proves it out with a procedural VECTOR shape instead of a sprite, so there's notexture 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
git add tools/glow_bake/live_check_centurion.gd tools/glow_bake/live_check_centurion.tscn docs/architecture/enemies-bosses-survival.mdgit 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.