How to design a new enemy
A sequential recipe for adding a new enemy archetype to Starship Survivors. Most new enemies are data-only and reuse an existing AI behavior. Custom AI is a separate, rarer task covered in step 6.
Before you start
Read this section first; it decides the whole shape of the work.
| Question | If yes | If no |
|---|---|---|
| Does an existing behavior already capture the AI you want? | Data-only — write one .ts file under data/enemies/, register it in the barrel, add to spawn pools. Skip step 6. | Plan a behavior addition in engine/enemies/behaviors.ts. Step 6 covers the contract. |
| Are you reskinning an existing archetype with different numbers? | Reuse the parent archetype’s behavior id and archetype string in the type def. Examples in data/enemies/index.ts: brute reuses charger, wisp reuses orb, lurker reuses sniper, bombardier reuses mortar, sprinter reuses racer, spitter reuses shooter, burner reuses field, suppressor reuses gunner. | Treat as a fresh archetype. Sprite/atlas wiring required (step 9). |
| Do you need a unique sprite? | Add an atlas region under HULL_SHAPE_MAP in data/enemies/index.ts (step 9). | Route to an existing polygon hull via HULL_SHAPE_MAP. |
| Is rarity-tier scaling per T1-T5 a special curve? | Extend the per-archetype block inside buildEnemyTypes() in data/enemies/index.ts (the same block that already custom-scales aoeRadius for orbs, blastRadius for mortars, etc.). | Just fill BaseArchetype fields once. The five-tier table is built for you. |
Data-only is the default path. Treat custom AI as a hard fork that needs a separate review.
Step 1: Pick the enemy’s identity
Fill this table on paper before you write any code. Every cell decides one field downstream.
| Slot | Options | Where it lands |
|---|---|---|
| Archetype role | chaser, shooter, orbiter, mortar/bomber, hitscan gunner, sniper, area-denial field, racer | archetype string in the type def; routes to behavior + polygon hull |
| AI behavior id | orb, charger, shooter, mortar, gunner, field, sniper, racer, orbit (legacy alias for shooter), stationary (friction-to-stop, fires from position) | behavior field on BaseArchetype |
| Weapon | weapon id from data/weapons.ts (enemy_charger, enemy_gunner, mortar_basic, rifle_basic, etc.) or null for melee/no weapon | weaponId |
| Personality biases (c, t, s) | combat / target / spatial — each 0.0-1.0 | PERSONALITY_PROFILES[archetype] in data/enemies/index.ts |
| Rarity tier eligibility | which of T1-T5 the archetype appears at | which spawn-pool bands you patch (step 7) |
| Intended planets / sets | bugs, city, bugs_mortar, bugs_shooter, bugs_charger, bugs_sniper, bugs_field, bugs_racer, bugs_mixed, bugs_heavy | spawn-pool patches in data/enemies/index.ts (step 7) |
| Contact damage profile | melee true/false; relies on weapon damage if shooter | melee flag + weaponId |
Reference personality biases currently in use (from PERSONALITY_PROFILES in data/enemies/index.ts):
| Archetype | combat (c) | target (t) | spatial (s) |
|---|---|---|---|
| orb | 0.3 | 0.8 | 0.2 |
| charger | 0.9 | 0.3 | 0.1 |
| shooter | 0.5 | 0.5 | 0.5 |
| mortar | 0.2 | 0.7 | 0.7 |
| gunner | 0.4 | 0.7 | 0.3 |
| field | 0.2 | 0.9 | 0.6 |
| sniper | 0.3 | 0.6 | 0.9 |
| racer | 0.8 | 0.2 | 0.1 |
The runtime adds +/- 0.15 jitter per spawn via rollPersonality() in engine/enemies/behaviors.ts.
Step 2: Write the data file
One .ts file per archetype under src/starship-survivors/data/enemies/. Export a single BaseArchetype const. The five rarity tiers are generated for you by buildEnemyTypes().
Every field in BaseArchetype (and per-archetype-tuning extensions on EnemyTypeDef):
| Field | Type | Required? | Meaning | Notes |
|---|---|---|---|---|
name | string | yes | Display name. Rarity suffix is appended automatically. | e.g. 'Charger' becomes 'Charger (Common)' … 'Charger (Legendary)' |
archetype | string | yes | Routes sprite + personality. | If reusing an existing hull, set to the parent archetype name AND add an explicit HULL_SHAPE_MAP entry. |
hp | number | yes | Base HP. Multiplied by RARITY_MULTS[rarity].hp per tier. | See multiplier table below. |
speed | number | yes | Base speed (px/s). Multiplied by RARITY_MULTS[rarity].speed. | |
radius | number | yes | Visual radius. Collision is radius * 3.85 (ENEMY_COLLISION_SCALE). | |
xp | number | yes | Base XP drop. Multiplied by RARITY_MULTS[rarity].xp. | |
weaponId | string | null | yes | Weapon to fire. null for melee or no-weapon. | |
behavior | string | yes | Behavior id from the registry. | See step 5 list. |
orbitRadius | number | yes | Used by shooter/mortar/orbit behaviors. 0 if not orbiting. | |
tags | string[] | yes | Free-form classification, e.g. ['fast'], ['mechanical']. The rarity tag is appended automatically. | |
melee | boolean | optional | Melee contact-damage flag. | true for charger, brute, etc. |
aoeRadius | number | orb-family only | Base AoE radius. Per-tier mult: 1 + tierIndex * 0.2. | |
aoeCooldown | number | orb-family only | Base AoE cooldown (s). Per-tier mult: [1.0, 0.85, 0.7, 0.35, 0.35]. | |
aoeDamage | number | orb-family only | Base AoE damage. Multiplied by RARITY_MULTS[rarity].damage. | |
blastRadius | number | mortar/bombardier | Base blast radius. Per-tier mult: 1 + tierIndex * 0.15. | |
fuseTime | number | mortar/bombardier | Fuse seconds. Not scaled per tier. | |
hitscanRange | number | gunner/suppressor | Hitscan range (px). Not scaled per tier. | |
burstCount | number | gunner/suppressor | Shots per burst. Per-tier mult: [1.0, 1.0, 1.2, 1.4, 2.0]. | |
burstCooldown | number | gunner/suppressor | Burst cooldown (s). Per-tier mult: [1.0, 0.9, 0.8, 0.65, 0.5]. | |
fieldRadius | number | field/burner | Field radius. Per-tier mult: 1 + tierIndex * 0.15. | |
fieldFadeIn | number | field/burner | Fade-in time (s). Not scaled. | |
fieldDuration | number | field/burner | Duration (s). Legendary multiplies by 1.5. | |
fieldDamage | number | field/burner | Damage. Multiplied by RARITY_MULTS[rarity].damage. | |
sniperChargeTime | number | sniper | Charge time (s). Per-tier mult: [1.0, 0.9, 0.8, 0.7, 0.5]. | |
sniperBeamDamage | number | sniper | Beam damage. Multiplied by RARITY_MULTS[rarity].damage. |
Rarity multipliers applied to base values (RARITY_MULTS in _types.ts):
| Rarity | hp | xp | speed | radius | damage |
|---|---|---|---|---|---|
| common | 2.7 | 3.0 | 1.40 | 1.0 | 3.0 |
| uncommon | 4.95 | 6.0 | 1.47 | 1.15 | 4.5 |
| rare | 6.93 | 12.0 | 1.57 | 1.3 | 6.6 |
| epic | 9.9 | 18.0 | 1.68 | 1.4 | 8.4 |
| legendary | 11.88 | 24.0 | 1.82 | 1.5 | 10.5 |
Rarity stroke tints (RARITY_TINTS):
| Rarity | Tint |
|---|---|
| common | #cccccc |
| uncommon | #33cc55 |
| rare | #3388ff |
| epic | #aa44ff |
| legendary | #ff8800 |
The designer enters base values only. Per-rarity scaling is computed at build time.
Minimal example (melee chaser, no weapon — modeled on charger.ts):
import type { BaseArchetype } from './_types';
export const myEnemy: BaseArchetype = {
name: 'My Enemy',
archetype: 'charger', // reuse charger hull + personality
hp: 44,
speed: 110,
radius: 10,
xp: 12,
weaponId: 'enemy_charger',
behavior: 'charger',
orbitRadius: 0,
tags: ['fast'],
melee: true,
};Shooter example with archetype-specific tuning (modeled on gunner.ts):
import type { BaseArchetype } from './_types';
export const myShooter: BaseArchetype = {
name: 'My Shooter',
archetype: 'gunner',
hp: 3,
speed: 63,
radius: 3.75,
xp: 15,
weaponId: 'enemy_gunner',
behavior: 'gunner',
orbitRadius: 0,
tags: ['mechanical'],
hitscanRange: 65,
burstCount: 3,
burstCooldown: 2.5,
};Orbiter example (modeled on orb.ts):
import type { BaseArchetype } from './_types';
export const myOrb: BaseArchetype = {
name: 'My Orb',
archetype: 'orb',
hp: 19,
speed: 29,
radius: 3.75,
xp: 15,
weaponId: null,
behavior: 'orb',
orbitRadius: 0,
tags: ['mechanical'],
aoeRadius: 32,
aoeCooldown: 0.8,
aoeDamage: 10,
};Step 3: Register the archetype
Add the import + BASE_ARCHETYPES entry in data/enemies/index.ts:
import { myEnemy } from './myEnemy';
const BASE_ARCHETYPES: BaseArchetype[] = [
orb, charger, shooter, mortar,
gunner, field, sniper, racer,
brute, wisp, lurker, bombardier,
sprinter, spitter, burner, suppressor,
myEnemy, // append
];Order matters only for diff readability — the iteration produces one EnemyTypeDef per (archetype, rarity) pair. The top-level lookup data/enemies.ts is a barrel that re-exports everything from ./enemies/index.ts; no edits needed there.
If you reuse an existing hull, add the explicit HULL_SHAPE_MAP entry inside the for (const rarity of RARITY_ORDER) block at the bottom of index.ts:
HULL_SHAPE_MAP[`myEnemy_${rarity}`] = 'charger'; // route to charger polygonIf you add a unique sprite, see step 9.
Step 4: Pick existing AI behaviors first
The behavior registry lives in engine/enemies/behaviors.ts. Each behavior declares which BaseArchetype / EnemyTypeDef fields it reads.
| Behavior id | One-line summary | Smart range (px) | Fields read |
|---|---|---|---|
orb | Fast tracker, dash-sploot AoE shock field with forecast circle, boid flocking. | 200 | hp, speed, radius, aoeRadius, aoeCooldown, aoeDamage |
charger | Approaches on a line, lunges at close range with knockback. Melee contact damage. | 350 | hp, speed, radius, melee, weapon stats from weaponId |
shooter | Orbit at range, fire aimed projectile with forecast line. | 250 | hp, speed, radius, orbitRadius, weaponId |
mortar | Stay at long range, lob timed area strikes with circle forecast. | 350 | hp, speed, radius, orbitRadius, weaponId, blastRadius, fuseTime |
gunner | Close-range hitscan burst with charge-up, soldier-march movement. | 200 | hp, speed, radius, hitscanRange, burstCount, burstCooldown |
field | Area denial — plants and creates an electric damage zone. | 350 | hp, speed, radius, fieldRadius, fieldFadeIn, fieldDuration, fieldDamage |
sniper | Long-range beam, always fires, blocked by terrain. | 400 | hp, speed, radius, sniperChargeTime, sniperBeamDamage |
racer | High-speed traffic, follows lane waypoints, contact damage. Never enters dumb-mode. | 9999 | hp, speed, radius |
orbit | Legacy alias — redirects to shooter behavior. | 250 | same as shooter |
stationary | Legacy — friction-to-stop, fire from position. Not knockbackable. | inherited | hp, radius, weaponId |
static | Used by boss roster (e.g. Solaris Hauler, Oracle). Speed-0 anchor. | n/a | hp, radius |
kite | Used by Killer Croc boss body. Charger-family kiting. | n/a | charger-family fields |
Smart range is the distance beyond which the enemy switches to cheap dumb-mode movement (seekPoint toward the player, no combat). Enemies mid-attack always run full behavior regardless. The racer smart range of 9999 means it always runs full behavior.
Pick a behavior, then go back to step 2 and make sure the relevant fields are populated.
Step 5: When you DO need custom AI
Register a new behavior in engine/enemies/behaviors.ts via EnemyBehaviors.register(id, handler). The handler implements the EnemyBehaviorHandler interface:
| Hook | Signature | When it runs | Required? |
|---|---|---|---|
id | string | Set automatically by register(). | no |
desc | string | Free-form description, shown in tooling. | optional |
smartRange | number (px) | Distance threshold above which dumb-mode movement runs instead of movement/combat. | optional (defaults to full-behavior always) |
movement | (e, dt, ship, world) => void | Every frame, when in full-behavior range or mid-attack. Owns velocity + position integration. | yes for most |
combat | (e, dt, ship, world) => void | Every frame, after movement. Owns weapon firing, AoE, beams. | optional |
onSpawn | (e, world) => void | Once when the enemy enters the world. Initialize per-enemy state here. | optional |
onDamage | (e, dmg, dctx) => void | Each hit. Use for stagger, aggro flips, phase changes. | optional |
onDeath | (e, world) => void | When HP hits 0. Use for explosions, child spawns, drops. | optional |
onStun | (e, duration) => void | When stunned. Cancel windups, clear charge timers. | optional |
render | (e, ctx, camera) => void | Per-frame custom overlay. Avoid unless you need forecast circles or unique FX. | optional |
useSharedAttackFSM | boolean | Opt into the shared attack state machine (attackState, atkTimer, etc.). | optional, default false |
useSharedSeparation | boolean | Opt into shared boid separation. | optional, default false |
useSharedAggro | boolean | Opt into shared aggro pulse handling. | optional, default false |
stunnable | boolean | Whether stun can be applied. | optional, default true |
knockbackable | boolean | Whether knockback can be applied. | optional, default true |
Custom AI lives alongside the existing handlers in behaviors.ts and is keyed by the string id set in the data spec’s behavior field. The enemy’s per-frame state (e.g. _orbPhase, _chargerPhase, _fireCharging, _burstRemaining, _gunnerFiring, _fieldPhase, _sniperPhase) is owned by the behavior and stored directly on the enemy object — use a unique underscore-prefixed namespace to avoid collisions, and add your phase checks to isMidAttack() in behaviors.ts so the dumb-mode optimization doesn’t interrupt your attacks.
Step 6: Spawn pools and elite eligibility
Spawn pools live in data/enemies/index.ts. Each pool is an array of progress bands; each band is an array of typeId strings sampled uniformly. To make an enemy “common at level 25”, you must add its typeId to the appropriate progress band (not just create the archetype).
| Pool constant | Used by | Set ids |
|---|---|---|
SPAWN_POOLS | Default bugs pool — orb-dominant + occasional charger | bugs |
CITY_SPAWN_POOLS | City pool — gunner-dominant + sniper/racer/field rares | city |
BUGS_MORTAR_POOLS | Solaris flavor | bugs_mortar |
BUGS_SHOOTER_POOLS | Speedway flavor | bugs_shooter |
BUGS_CHARGER_POOLS | Eden-5 flavor | bugs_charger |
BUGS_SNIPER_POOLS | Old Earth flavor | bugs_sniper |
BUGS_FIELD_POOLS | Network Station flavor | bugs_field |
BUGS_RACER_POOLS | Delphi flavor | bugs_racer |
BUGS_MIXED_POOLS | Obelisk flavor | bugs_mixed |
BUGS_HEAVY_POOLS | Desolation flavor | bugs_heavy |
Progress bands are: 0-8, 8-20, 20-40, 40-60, 60-999. Editing them is the only way to make a new archetype appear in the wild. Duplicate the typeId multiple times to weight the sample — pools are uniformly sampled, so frequency = duplicates / band-length.
For elite eligibility (the leader of an elite pack), edit SET_LEADER_ARCHETYPES in data/enemies/index.ts:
const SET_LEADER_ARCHETYPES: Record<EnemySetId, string[]> = {
bugs: ['charger', 'shooter', 'mortar'],
// …
bugs_racer: ['charger', 'racer', 'sprinter'],
// …
};pickEliteForSet() rolls the leader rarity as rare 50% / epic 30% / legendary 20% (subject to a rarity cap if passed). The follower archetype comes from SET_SWARM_ARCHETYPE (always orb for bugs sets, gunner for city).
Step 7: Boss-roster inclusion (optional)
The director’s boss encounter system runs spawn profiles defined in data/spawn-profiles.ts. A SpawnProfileDef is the timeline of pressure adds during a boss encounter — what / how many / when / where / how often.
| Field | Meaning |
|---|---|
id | Profile id referenced by a boss def. |
waves | Array of SpawnWaveDef. |
SpawnWaveDef.trigger | { kind: 'time'; at }, { kind: 'recurring'; every; from?; until? }, or { kind: 'phase'; index }. |
SpawnWaveDef.enemyTypeId | typeId from ENEMY_TYPE_MAP (rarity-cube ids or boss-roster ids in BOSS_ENEMY_TYPES). |
SpawnWaveDef.count | Number of enemies per wave. |
SpawnWaveDef.position | cardinal, ring, random, edge_random, opposite_player. |
SpawnWaveDef.affixIds | Optional affix ids applied to the wave. |
SpawnWaveDef.abilityIds | Optional ability ids applied to the wave. |
Built-in profiles in data/spawn-profiles.ts:
| Profile id | Behavior |
|---|---|
none | No adds. |
light_pressure | 3 orb_common every 8s from t=4. |
heavy_pressure | 5 gunner_common every 12s from t=2 at cardinal points. |
beacon_clearers | 2 charger_common every 15s from t=10 opposite the player. |
storm | 8 gunner_common every 20s from t=15 in a ring. |
crescendo | 2/4/6 gunner_common waves on time-0 / phase-1 / phase-2. |
To add a new enemy to a boss roster, either reference its existing rarity-cube id in a wave, or add a faction-specific entry to BOSS_ENEMY_TYPES in data/enemies/index.ts (which gives the enemy its own typeId, sprite identity, and HP override scaffolding — see existing entries like caiman, industria_loader, solaris_oracle). Boss roster entries override hp/hpMax at spawn time anyway, so the numbers there only matter if a non-boss code path spawns the type.
The director’s per-frame behavior is in engine/enemies/director.ts — it sets personality (GUARDIAN / CONDUCTOR / PREDATOR / DEMON) from heat (1-10), adjusts enemyFireRateMult and enemyAggroRangeMult, and tracks mood (tension, chaos, dread, triumph, calm, urgency, heatFeel, deathProximity, combatDensity, rewardMoment). Director state is read-only from the enemy perspective; no per-archetype edits are needed.
Step 8: VFX, audio, sprites
Sprites live in the rendering atlas. The mapping happens in data/enemies/index.ts via HULL_SHAPE_MAP:
for (const rarity of RARITY_ORDER) {
HULL_SHAPE_MAP[`myEnemy_${rarity}`] = 'myEnemy'; // unique hull
// or:
HULL_SHAPE_MAP[`myEnemy_${rarity}`] = 'charger'; // reuse charger polygon
}Boss-roster types use their own typeId as the shape key:
for (const et of BOSS_ENEMY_TYPES) {
HULL_SHAPE_MAP[et.id] = et.id;
}A unique hull means a new atlas region. New archetypes that need a unique silhouette need an atlas entry alongside polygon hull data. Reusing an existing hull is the cheap path. The stroke color per spawn comes from RARITY_TINTS[rarity] automatically; the tint field on EnemyTypeDef is set per spawn.
Step 9: Validate
Run this checklist before merging:
- Archetype appears in
BASE_ARCHETYPESindata/enemies/index.ts. -
ENEMY_TYPE_MAP[archetype_common]…ENEMY_TYPE_MAP[archetype_legendary]resolve. -
HULL_SHAPE_MAP[archetype_${rarity}]is set for all five rarities. - Archetype spawns in the expected progress band on the expected sets.
- AI behaves as intended (movement, attack windup, attack execution, cooldown).
- T1-T5 stat scaling produces sane numbers (sanity-check
hp,speed,radius,xp, archetype-specific tuning). - Contact damage matches design intent (
meleeflag set when intended; weapon damage tracksRARITY_MULTS[rarity].damage). - Drops feel right (XP per kill vs effort).
- No console errors on spawn, attack, or death.
- If new behavior was added,
isMidAttack()inengine/enemies/behaviors.tsrecognizes its in-progress states. - If elite-eligible, archetype name is in
SET_LEADER_ARCHETYPESfor the target sets.
Custom-element structure rule
Custom AI behaviors register through the behavior registry in engine/enemies/behaviors.ts. There are no inline special-cases in the spawn director (engine/enemies/director.ts) or the spawner. Data-only changes never touch engine code. Engine code only touches data via ENEMY_TYPE_MAP lookups and the behavior string. Keep the seam intact: data flows down, behavior fires sideways through the registry.