Centurion enemy — Nova Drift-style multiply-tint — design
Centurion enemy — Nova Drift-style multiply-tint — design
Section titled “Centurion enemy — Nova Drift-style multiply-tint — design”Date: 2026-07-06
Status: approved (design), pre-implementation
Determinism: a new enemy type touches the spawn roster. Following every prior new-enemy
addition, Centurion is gated to spawn only past the 600-tick determinism baseline’s early window
(same pattern as other non-starter types in SpawnDirector.pick_type), so the pinned baseline
(snapshot_string().hash()=4217109746, state_checksum()=2666143677) is expected to hold — must
be re-verified after wiring, not assumed.
1. Concept & scope
Section titled “1. Concept & scope”Chris read Nova Drift’s custom-skins blog post and asked for an enemy built with the same technique: player-color art applied multiplicatively onto pure white base art, so one asset renders correctly in any color.
Investigation found the render plumbing for this already exists and already ships, via
ArchetypeRenderer’s per-instance MultiMesh color (set_instance_color, with the core mesh’s
own modulate deliberately forced to Color.WHITE so the tint isn’t double-applied) — proven live
by the Warden’s calm/enrage 2-state tint. What’s missing is content built on genuinely neutral base
art: every existing enemy sprite (orbiter.png, warden.png, spider.png, ghost.png) is
pre-colored concept art, so today’s “tint” only nudges an already-hued image.
This spec adds one new enemy, Centurion, as the first true demonstration of the technique: a fully procedural (vector, no texture) shape whose only color comes from the per-instance multiply — and whose tint is driven automatically by the same per-dimension element-color mechanism every ordinary enemy already uses. No new tinting plumbing is needed; the payoff falls out of existing code once the shape itself is genuinely colorless.
Confirmed with Chris (via brainstorming):
- Tint driver: per-dimension element color (Fire/Void/Light/Home), not a fixed signature color or an elite-only affix variant.
- Archetype: new ranged/shooter type.
- Art: a Gemini-web-generated concept image, then a procedural/vector in-engine shape — not a raster sprite.
- Segmentation is a visual detail only (plated/ridged silhouette) — one hitbox, one entity, standard movement. Not a real multi-part chained body (that would be a much bigger new mechanic, explicitly out of scope).
- Name: Centurion.
- Attack: reuses the existing Shooter’s simple aimed-shot pattern, not a new radial-burst attack.
Out of scope
Section titled “Out of scope”- No new attack pattern (a radial “spoke burst” tied to the gear silhouette was considered and explicitly deferred — Chris chose the simpler aimed shot to keep this focused on the visual technique, not new combat logic).
- No boss variant.
- No elite-affix-specific recolor — Centurion gets the standard elite lerp-toward-
ELITE_AGGRO_COLORtreatment like every other type if it rolls elite, same as today. - Not retrofitting the multiply-tint technique onto any existing pre-colored sprite (Orbiter/Warden/Spider/Ghost) — those are unchanged. This spec only concerns the one new enemy.
- No real multi-segment/chained body.
2. Visual design
Section titled “2. Visual design”Reference concept art generated via Gemini web, saved to
concept-art/gemini/14_enemy-centurion-concept.png (gitignored WIP reference, not a runtime
asset): a circular disc of overlapping segmented plates radiating from a small central core — like
a rolled pill bug, a trilobite, or a gear, viewed directly from above. Pure white/light-grey, which
is exactly the base the multiply technique needs.
Rather than baking this into a sprite, build it as vector geometry already native to the
renderer. render/archetype_renderer.gd already has a generalized star-polygon builder:
func _star_pts(points: int, outer: float, inner: float) -> PackedVector2Array: var pts := PackedVector2Array() for k in range(points * 2): var a := float(k) / float(points * 2) * TAU - PI / 2.0 var r2 := outer if k % 2 == 0 else inner pts.append(Vector2(cos(a), sin(a)) * r2) return ptsSHAPE_STAR already calls this with _star_pts(6, radius, radius * 0.45) — few points, deep
valleys, reads as a spiky star. Centurion reuses the same function with more points and a much
shallower valley, which reads as a scalloped “gear/rosette” disc instead:
- New shape constant
SHAPE_ROSETTE(SHAPE_COUNTbumped 19 → 20). _mesh_for_shapematch arm:SHAPE_ROSETTE: return _polygon_mesh(_star_pts(12, radius, radius * 0.80)).- Starting params (12 points, 0.80 inner ratio) are a tunable starting point — iterate visually against the reference art before merging; there is no “correct” number here, just what reads well as plated segments at swarm scale.
- No
_SPRITE_FOR_SHAPEentry, no_texture_for_shapechange. Staying on the plain per-instance-color polygon path (same as every procedural shape, unlike the four sprite-quad shapes) is what makes the multiply-tint fully clean — there’s no pre-baked hue to fight. - Wired only to
TYPE_CENTURIONin_TYPE_SHAPEinitially — a dedicated bucket, same pattern asSHAPE_STARBURST(accumulator-only) rather than a shared shape.
3. Tint behavior (the actual “Nova Drift” part)
Section titled “3. Tint behavior (the actual “Nova Drift” part)”Nothing new to build here — this is the point of the design. main.gd’s _base_color_for_type
already special-cases boss/mini-boss identity colors, and falls through to a generic branch for
every ordinary enemy type:
if el >= 0 and el < _element_color_lut.size(): return ElementPalette.vivid(_element_color_lut[el]) # the enemy's own innate-element colourel is the enemy’s live base_element, already set per-instance by the existing Elemental
Dimensions v2 reflavoring system when spawned inside Fire/Void/Light/Home, falling back to its
bible.json home element otherwise. As long as Centurion is not added to the boss/mini-boss
special cases above that branch, it automatically renders fire-orange in Fire, void-purple in
Void, light-gold in Light, and its home-element color in the base Survival roster — with zero
Centurion-specific color code. The only requirement is that the base shape itself carries no
baked-in hue, which the plain vector polygon (§2) guarantees.
Correction found during planning (2026-07-06): color and spawn eligibility are two
separate gates, and only the first was covered above. sim/area_defs.gd’s _DEFS curates a
short enemy_types allow-list per area — including "home", which is_dimension() counts as a
dimension too (it has a "boss" field), so even the default Survival roster is already
curated, not “everything spawns, just recolored.” Sim._dimension_allowed_types() /
_remap_to_dimension() (used by the swarm-burst path) and SpawnTable.pick()’s allowed param
(used by the main per-wave spawner, Sim._spawn_wave) both filter candidates against this list
before color is ever considered. A type absent from an area’s enemy_types simply never spawns
there — it does not fall back to spawning untinted. So for Centurion to actually appear (and thus
for the multi-color payoff to be reachable in real play), it must be added to enemy_types for
home, fire, void_dim, and light, with an enemy_elements override entry on the latter
three (matching the existing pattern every other multi-dimension “guest” type already uses — e.g.
TYPE_ORBITER is a native-cold type reflavored to void in void_dim, and to light in light).
home needs no override since Centurion’s own bible-native element (aether) is its intended
“default” look. This is folded into the wiring list in §5 below.
4. Archetype / behavior
Section titled “4. Archetype / behavior”archetype: "ranged", same family as the existing Shooter. Reuses the aimed-shot-at-nearest-pilot
pattern already implemented in sim/enemy_attacks.gd’s update_shooters(), rather than a new
attack loop:
# enemy_attacks.gd:101 (current)if sim.enemies.type_id[i] != EnemyPool.TYPE_SHOOTER: continueextends to also match TYPE_CENTURION, sharing the same fire-interval/projectile logic
(SHOOTER_FIRE_INTERVAL, SHOOTER_PROJ_SPEED, elite lead-aim handling, etc.) rather than
duplicating it — Centurion is a reskin of the Shooter’s combat behavior, not a new one.
Data entry (data/bible.json, modeled on shooter)
Section titled “Data entry (data/bible.json, modeled on shooter)”| field | value | notes |
|---|---|---|
id |
centurion |
|
name |
Centurion |
|
archetype |
ranged |
|
hp |
14 | vs Shooter’s 8 — armored-sentry identity, tunable |
speed |
45 | vs Shooter’s 55 — slower, sturdier, tunable |
radius |
14 | matches Shooter |
contact_damage |
10 | matches Shooter |
xp_value |
4 | vs Shooter’s 3, reflecting higher HP |
armor |
2 | new — Shooter has 0; reflects “armored plates” |
element |
aether |
neutral/tech home element, matching Skirmisher’s aether; overridden per-dimension per §3 |
tags |
["ranged"] |
|
live |
true |
|
biomass |
4 | matches xp_value, following the existing convention where these track together |
All numeric values above are tunable — this is a first pass to playtest against, not a balance conclusion.
5. Wiring (per the bh-add-content skill checklist)
Section titled “5. Wiring (per the bh-add-content skill checklist)”data/bible.json— newenemiesentry per §4 table (hand-edit only, per this repo’s convention —tools/design-bible/src/seed.jshas drifted behind the hand-maintained file and must not be used to re-export).sim/enemy_pool.gd+sim/sim.gd_build_enemy_types—const TYPE_CENTURION := 23(next free id afterTYPE_CUSTOM = 22),TYPE_NAMESentry, and extend_enemy_typesto cover the new index (foldingTYPE_CUSTOM’s previously-separate “safe defaults” block into the same resize, since it now sits below Centurion in the array).sim/area_defs.gd— addTYPE_CENTURIONtoenemy_typesforhome,fire,void_dim, andlight, plus anenemy_elementsoverride entry on the latter three, per the correction in §3. Without this step Centurion cannot spawn at all in any area, regardless of every other step.sim/spawn_table.gd— the actual per-wave spawn probability table (SpawnTable, drivingSim._spawn_wave, the primary spawn path — notSpawnDirector.pick_type, which only feeds the rare one-time swarm-burst event). AddTYPE_CENTURIONtoUNLOCK_ORDER(last/latest position) and to the two latest time-buckets intable()only (150s and 180s+), never the 0s/30s/60s/90s/ 120s buckets — this keeps it outside both the wave-gate and the time-weighted pick for the entire 600-tick/10-second determinism baseline window, so the baseline is structurally unaffected (traced throughSpawnTable.pick’s filter logic, not just assumed).main.gd_build_enemy_type_colors— resize the LUT to cover id 23 (sized to max type id, not entry count — a known trap per thebh-add-contentskill).render/archetype_renderer.gd— addSHAPE_ROSETTEper §2, bumpSHAPE_COUNT, extend_TYPE_SHAPE, add the_mesh_for_shapematch arm. A type with no renderer entry is invisible with no error — this step is not optional.sim/enemy_attacks.gdupdate_shooters— extend theTYPE_SHOOTERcheck per §4.data/bestiary.json+EnemyPool.TYPE_NAMES— display name “Centurion” (bestiary name can diverge from the bible id, per existing convention).- Re-run
tests/test_determinism_checksum.gd+test_determinism_crystals.gdafter wiring — read the literal pinned assertion, not this document, as the source of truth.
6. Verification
Section titled “6. Verification”- Visual: an isolated shape-preview screenshot (following this repo’s existing
tools/ship_preview/ temp_shot.gdharness pattern) renderingSHAPE_ROSETTEtinted white (identity — should look like flat grey plated geometry) and then in each of the four dimension colors, compared side by side against the Gemini reference art and against Shooter’s existing diamond silhouette for shape-distinctness at swarm scale. A capture that merely “looks plausible” is not proof — diff it against an untinted baseline, per this project’s established gotcha with screenshot verification. - Gameplay (GUT): Centurion spawns, has the stats from §4, fires an aimed shot at the nearest
pilot on the Shooter’s cadence, dies and grants XP/gold normally, and is invisible/absent unless
SHAPE_ROSETTEis wired — assert the shape mapping directly rather than only visually. - Determinism: re-run and re-pin per §5 step 8 if (and only if) the checksum actually moves.
- On-device: not required before merge — matches how recent enemy/content additions (e.g. ship classes) merged Mac-only first — but flagged as owed before any Apple TV deploy, since every new enemy needs a real playtest for readability/legibility at TV viewing distance.