Skip to content

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.

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.
  • 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_COLOR treatment 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.

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 pts

SHAPE_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_COUNT bumped 19 → 20).
  • _mesh_for_shape match 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_SHAPE entry, no _texture_for_shape change. 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_CENTURION in _TYPE_SHAPE initially — a dedicated bucket, same pattern as SHAPE_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 colour

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

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:
continue

extends 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)”
  1. data/bible.json — new enemies entry per §4 table (hand-edit only, per this repo’s convention — tools/design-bible/src/seed.js has drifted behind the hand-maintained file and must not be used to re-export).
  2. sim/enemy_pool.gd + sim/sim.gd _build_enemy_typesconst TYPE_CENTURION := 23 (next free id after TYPE_CUSTOM = 22), TYPE_NAMES entry, and extend _enemy_types to cover the new index (folding TYPE_CUSTOM’s previously-separate “safe defaults” block into the same resize, since it now sits below Centurion in the array).
  3. sim/area_defs.gd — add TYPE_CENTURION to enemy_types for home, fire, void_dim, and light, plus an enemy_elements override 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.
  4. sim/spawn_table.gd — the actual per-wave spawn probability table (SpawnTable, driving Sim._spawn_wave, the primary spawn path — not SpawnDirector.pick_type, which only feeds the rare one-time swarm-burst event). Add TYPE_CENTURION to UNLOCK_ORDER (last/latest position) and to the two latest time-buckets in table() 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 through SpawnTable.pick’s filter logic, not just assumed).
  5. 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 the bh-add-content skill).
  6. render/archetype_renderer.gd — add SHAPE_ROSETTE per §2, bump SHAPE_COUNT, extend _TYPE_SHAPE, add the _mesh_for_shape match arm. A type with no renderer entry is invisible with no error — this step is not optional.
  7. sim/enemy_attacks.gd update_shooters — extend the TYPE_SHOOTER check per §4.
  8. data/bestiary.json + EnemyPool.TYPE_NAMES — display name “Centurion” (bestiary name can diverge from the bible id, per existing convention).
  9. Re-run tests/test_determinism_checksum.gd + test_determinism_crystals.gd after wiring — read the literal pinned assertion, not this document, as the source of truth.
  • Visual: an isolated shape-preview screenshot (following this repo’s existing tools/ship_preview / temp _shot.gd harness pattern) rendering SHAPE_ROSETTE tinted 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_ROSETTE is 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.