How to design a new boss
A sequential recipe for adding a boss encounter to Starship Survivors. A boss is one or more enemy “bodies” inside a sealed arena, anchored by a shared HP bar. Most new bosses are data-only: pick existing AI behaviors, wire one or more of the nine registered ability patterns, attach a spawn profile for add-pressure, and pick rewards. Custom AI or a new ability pattern is a separate, rarer task covered in step 9.
Before you start
Read this section first; it decides the whole shape of the work.
| Question | If yes | If no |
|---|---|---|
| Does the encounter fit a single body, multi-body, or anchored composition you’ve seen in the existing roster? | Data-only — write one .ts file under data/bosses/, register it in data/bosses/index.ts. | You probably want a new AI behavior; that is a fork in engine/enemies/behaviors.ts. |
| Can the boss’s threats be expressed with the existing 9 ability patterns? | Data-only — reference patterns by id, tune their params payload. | New PatternId + fire fn in engine/abilities/patterns.ts. |
| Will the encounter use an existing spawn profile for pressure adds? | Reference the profile id (e.g. 'crescendo', 'beacon_clearers'). | Add a new SpawnProfileDef in data/spawn-profiles.ts. |
| Is the boss the full final-tier encounter, or a level-roll mini-boss? | Set kind: 'mini' (default — all currently shipped bosses) or kind: 'boss'. | — |
Bosses are pulled from BOSS_DEFS (in data/bosses/index.ts) and assigned at run-roll time. pickRandomBossId('mini') and pickRandomBossId('boss') partition the pool by kind. The arena and roster are materialized by spawnBoss(defId, game, world, kind) in engine/boss/encounter.ts.
Step 1: Identity
Lock these decisions first. Each maps to a top-level BossDef field.
| Slot | Field | Notes |
|---|---|---|
| Stable id | id | Used as the registry key. Lowercase snake_case. Examples: awakened_mech, hive_queen, reactor_core. |
| Display name | displayName | Shown on the HUD boss bar. Uppercase by convention. |
| Bar color | barColor | Hex #rrggbb. Tints the HUD bar; per-body override is available in the roster entry. |
| Pool tag | kind | 'mini' (default) — drawn from the mini-boss pool. 'boss' — drawn from the boss-tier pool. |
| Phase count | implicit | Phase shifts come from the respawn_as affix on the anchor body (see step 4). A single-phase boss carries no respawn_as affix. |
| Theme / palette | (data only) | Palette colors are author-defined constants inside the boss file; they feed the VFX kit setup hooks. |
| Biome assignment | (run-side) | Bosses are not directly tagged to biomes in BossDef; the run sequence in data/level-progression.ts decides which boss rolls for a given level kind. |
Place your new file at src/starship-survivors/data/bosses/<your-boss-id>.ts, export the BossDef const, then add the import + assignment in data/bosses/index.ts:
import { yourBoss } from './your-boss';
BOSS_DEFS[yourBoss.id] = yourBoss;Step 2: Bodies and roles
The roster is an array of BossRosterEntry. Each entry produces count enemies at the given position. Bodies share the bar pool via sharesHealthWithBoss. Exactly one body is the bar anchor (isBoss: true). The anchor’s displayName is what shows on the HUD bar when the BossDef lookup falls through.
| Composition | Example | How it’s encoded |
|---|---|---|
| Single body, single phase | (no shipped example — simplest case) | One entry, count: 1, isBoss: true, sharesHealthWithBoss: true, no respawn_as affix. |
| Single body, multi-phase | Awakened Mech | One entry with respawn_as affix in affixIds; affixState.respawn_as.phases lists thresholds and next typeIds. |
| Bar anchor + shielded anchors | Hive Queen | Anchor entry holds shielded_respawn; anchors are configured via affixState. |
| Bar anchor (untargetable) + sharing satellites | Reactor Core | Anchor: isBoss: true, untargetable: true, sharesHealthWithBoss: false. Satellites: separate entry, isBoss: false, sharesHealthWithBoss: true. |
| Many-body swarm | Digbot Swarm | Foreman entry (count: 1, isBoss: true) + swarm entry (count: 99, isBoss: false, both sharesHealthWithBoss: true). |
Per-entry fields:
| Field | Type | Meaning |
|---|---|---|
enemyTypeId | string | Registered enemy type that supplies sprite + base stats. |
position | 'center' | 'cardinal' | 'ring' | 'random' | { x, y } | Where the bodies spawn inside the arena. Object form is arena-relative. |
count | number | Bodies generated from this entry. |
isBoss | boolean | Marks the bar anchor. Exactly one body should be the anchor. |
sharesHealthWithBoss | boolean | Damage to this body drains the shared bar pool. |
untargetable | boolean | If true, the body cannot be auto-targeted (used by Reactor Core’s Hauler). |
aiId | string | Maps to a behavior. Allowed values: kite_player, hold_center, orbit_center, patrol_random, formation_lock, disperse_to_fill. Unknown ids crash at spawn. |
affixIds | string[] | Affix ids (e.g. respawn_as, shielded_respawn, gated). Configured via affixState. |
affixState | Record<affixId, Record<string, unknown>> | Per-affix initial state (anchor counts, phase thresholds, gate group ids, etc.). |
abilityIds | string[] | Ability ids resolved against ABILITY_REGISTRY. Each becomes an AbilityInstance on the spawned body. |
hp | number | Per-body HP. Multiplied at spawn by the kind multiplier and depth ramp. |
barColor | string | Optional per-body bar/outline color override. |
displayName | string | Optional override for non-anchor bodies (inspection/debug labels). |
AI id → behavior mapping (from mapAiIdToBehavior in engine/boss/encounter.ts):
aiId | Behavior |
|---|---|
kite_player | kite |
hold_center | static |
orbit_center | orbit |
patrol_random | patrol |
formation_lock | static |
disperse_to_fill | patrol |
Step 3: Phases and ability schedules
A multi-phase boss uses the respawn_as affix on the anchor. Each phase swaps the host enemy type (and its sprite) and replaces the active ability set, while preserving the shared HP bar.
affixState.respawn_as.phases shape:
| Key | Type | Meaning |
|---|---|---|
thresholds | number[] | Sorted descending HP fractions of hpMax that fire the swap. Example: [0.66, 0.33] fires at 66% and 33%. |
nextTypes | string[] | Aligned by index with thresholds. Enemy typeId to swap into on each cross. |
nextAbilityIds | string[][] | Aligned by index. Ability id lists for the new body. |
nextAffixIds | string[][] | Aligned by index. Affix ids to carry into the new body. Include 'respawn_as' again on every non-terminal phase to chain further transitions. |
Awakened Mech reference values:
| Phase | Threshold | Next body type | Active abilities |
|---|---|---|---|
| 0 (spawn) | — | industria_loader | mech_phase1_aimed_volley |
| 1 | 0.66 | industria_drillbot | mech_phase2_spiral_vortex |
| 2 (terminal) | 0.33 | industria_bigbot | mech_phase3_cone_slam |
Phase-1 ability example (registered at module init via registerAbility):
| Param | Value |
|---|---|
pattern | aimed_volley |
cooldown | 2.0 s |
telegraphMs | 800 |
count | 5 |
spread | 15° |
bulletSpeed | 240 |
bulletDamage | 12 |
bulletRadius | 5 |
Phase-2 ability (sustained pattern):
| Param | Value |
|---|---|
pattern | spiral_vortex |
cooldown | 8.0 s |
bulletPerEmit | 2 |
emitIntervalMs | 100 |
rotationSpeedDegPerSec | 270 |
sustainMs | 4000 |
bulletSpeed | 180 |
Phase-3 ability:
| Param | Value |
|---|---|
pattern | cone_slam |
cooldown | 3.0 s |
telegraphMs | 1500 |
length | 300 |
halfWidth | 70° |
damage | 35 |
bulletCount | 8 |
respawn_as fires BossDef.onPhaseChanged(host, newPhaseIndex, world, arena, kit) after the body swap. Use that hook to swap host-attached persistent VFX layers (the previous host died, so LayerHandles for the old host are dangling) and stage a transition cinematic.
Step 4: Abilities
Bosses fire abilities by id from ABILITY_REGISTRY. The registry is seeded from data/abilities.ts (static catalog) and grown at module init by per-boss registerAbility(def) calls. Each AbilityDef carries a pattern: PatternId plus a params: Record<string, AbilityParamValue> payload that the pattern fire fn consumes.
The nine registered patterns (from engine/abilities/index.ts and patterns.ts):
| Pattern | Type | Behavior |
|---|---|---|
radial_burst | single-shot, telegraphed | N bullets in 360° outward from caster. Telegraph paints a bloom around the caster. |
aimed_volley | single-shot, telegraphed | N bullets toward the player’s locked-at-telegraph-start position; bullets fan across spread degrees. |
cone_slam | single-shot, telegraphed | Wedge AOE in front of caster; chunky bullets fan across the cone, traveling length distance. |
telegraphed_circles | single-shot, telegraphed | M ground AOE circles painted at arena positions; detonate after telegraph. |
beam_sweep | sustained | Laser rotates from caster across an arc; deterministic per-frame ship-on-line damage. |
spiral_vortex | sustained, no telegraph | Bullets emit in a rotating Touhou-style spiral for sustainMs. |
wall_sweep | single-shot, telegraphed | Wall of bullets crosses arena with N safe gaps; alternates start side per fire. |
spawn_telegraph | single-shot, telegraphed | Painted circles spawn enemies at the configured enemyTypeId after telegraph. |
death_beam | sustained | Single arena-bisecting kill beam; instakill damage on the line. |
Per-pattern params (cooldown is the time between fires once any sustain finishes):
| Pattern | Required params | Optional params |
|---|---|---|
radial_burst | count, bulletSpeed, bulletDamage, bulletRadius | telegraphMs (default 1000), bulletColor (default #ff5500) |
aimed_volley | count, bulletSpeed, bulletDamage, bulletRadius | spread (default 0°), telegraphMs (default 800), bulletColor (default #ffaa00) |
cone_slam | length, halfWidth (deg), damage | bulletCount (default 8), telegraphMs (default 1200), bulletColor (default #ff3300) |
telegraphed_circles | circleCount, circleRadius, circleDamage | telegraphMs (default 1500), arenaPositions (random / cardinal / ring, default random), bulletColor (default #ff8800) |
beam_sweep | arcDegrees, sweepDurationMs, damagePerSec | telegraphMs (default 1000), beamLength (default arena radius), bulletColor (default #ff0066) |
spiral_vortex | sustainMs, emitIntervalMs, bulletPerEmit, bulletSpeed, rotationSpeedDegPerSec | bulletColor (default #cc66ff) |
wall_sweep | wallSpeed, gapCount, gapWidth, bulletDamage | direction (horizontal / vertical, default horizontal), telegraphMs (default 2000), bulletColor (default #ff0044) |
spawn_telegraph | circleCount, enemyTypeId | positions (cardinal / ring / random, default cardinal), affixIds, telegraphMs (default 1500) |
death_beam | sustainMs, lineWidth, damageInstaKill | telegraphMs (default 3000), bulletColor (default #ff0000) |
AbilityDef also supports startDelay (seconds) to delay the first fire after a body spawns. cooldown ticks start when the previous fire’s sustain phase ends, not at fire time.
Two registration paths:
| Path | When to use |
|---|---|
Static catalog in data/abilities.ts (ABILITY_DEFS) | Generic templates reused across enemies or bosses. |
registerAbility(def) at module init inside the boss file | Boss-specific tuning that should live next to the boss data and not bloat the global table. Awakened Mech, Hive Queen, Digbot Swarm, Reactor Core all use this path. |
Step 5: Spawn profile
The boss spawnProfileId selects a pressure-add timeline that runs alongside the encounter. Profiles live in data/spawn-profiles.ts. Each profile has an array of SpawnWaveDef entries; the runtime in engine/enemies/boss-spawn-profile.ts ticks them against game.bossEncounterTime.
Existing profiles:
| Profile id | Trigger | Wave |
|---|---|---|
none | — | Empty. Use when the boss’s own roster is the entire fight (e.g. Digbot Swarm). |
light_pressure | recurring every 8s from t=4s | 3 × orb_common at edge_random |
heavy_pressure | recurring every 12s from t=2s | 5 × gunner_common at cardinal |
beacon_clearers | recurring every 15s from t=10s | 2 × charger_common at opposite_player |
storm | recurring every 20s from t=15s | 8 × gunner_common at ring |
crescendo | one-shot t=0 + phase index 1 + phase index 2 | 2 / 4 / 6 × gunner_common (cardinal then ring then ring) |
Trigger shapes (SpawnTrigger):
| Kind | Fields | Meaning |
|---|---|---|
time | at (seconds) | One-shot at the given encounter time. |
recurring | every, optional from, optional until | Recurring cadence inside an optional time window. |
phase | index | Fires when the anchor crosses into phase index N (driven by respawn_as). |
Position values: cardinal, ring, random, edge_random, opposite_player.
To add a new profile: append a SpawnProfileDef to SPAWN_PROFILES and reference its id from the boss’s spawnProfileId.
Step 6: Scaling
Spawn-time scaling layers two factors onto each body’s base hp and damageMult:
| Factor | Source | Value |
|---|---|---|
| Kind multiplier (HP) | BOSS_KIND_HP_MULT_MINI / BOSS_KIND_HP_MULT_BOSS | mini = 4.0, boss = 8.0 |
| Kind multiplier (damage) | BOSS_KIND_DAMAGE_MULT_MINI / BOSS_KIND_DAMAGE_MULT_BOSS | mini = 4.0, boss = 8.0 |
| Depth ramp | linear over the run level sequence | 1.00 × at level 1 → 1.50 × at the final boss level |
Final per-body multipliers at spawn:
hpMult = kindHpMult * (1.00 + 0.50 * depthFrac)
dmgMult = kindDmgMult * (1.00 + 0.50 * depthFrac)
depthFrac = (currentLevel - 1) / (totalLevels - 1), clamped to [0, 1]. totalLevels is 5 in normal mode, 10 in challenge mode.
boss-scaling.ts defines a richer multi-dimensional scaling config (DEFAULT_BOSS_SCALING) that some systems consume directly (turret fire rate, move speed, limb sweep, spawner frequency, projectile speed, effect radius, enrage timer). New bosses inherit the defaults; per-boss overrides slot into the boss def via custom code paths if needed.
Default scaling dimensions (DEFAULT_BOSS_SCALING in data/boss-scaling.ts):
| Dimension | perLevel | capValue | capSteepness | capType |
|---|---|---|---|---|
hp | 2.00 | — | — | uncapped |
damage | 2.15 | — | — | uncapped |
turretFireRate | 1.25 | 3.0 | 0.30 | soft_cap |
moveSpeed | 1.10 | 2.0 | 0.25 | soft_cap |
limbSweepSpeed | 1.15 | 2.5 | 0.20 | soft_cap |
spawnerFrequency | 1.20 | 3.0 | 0.25 | soft_cap |
projectileSpeed | 1.12 | 2.0 | — | hard_cap |
effectRadius | 1.10 | 2.5 | 0.20 | soft_cap |
enrageTimer | 0.88 | 0.25 | 0.30 | soft_cap |
BASE_ENRAGE_TIMER_SEC = 120 is the level-1 baseline enrage timer; per-boss defs can override.
Step 7: Rewards
BossDef.reward is a lump payout granted on win:
| Field | Type | Meaning |
|---|---|---|
xp | number | Lump XP granted by the boss_kill signal. |
currency | number | Lump currency. |
extraRewardPicks | number | Additional level-up pick rolls offered after the encounter. |
Reference values from shipped bosses:
| Boss | xp | currency | extraRewardPicks |
|---|---|---|---|
| Awakened Mech | 1200 | 120 | 2 |
| Digbot Swarm | 1000 | 100 | 2 |
| Reactor Core | 950 | 100 | 1 |
| Hive Queen | 900 | 90 | 1 |
BossDef.rewardCascadeTypes (optional) tunes the Supply Pod cascade ring spawned at the death point. Five prop typeIds form a ring around the kill anchor. If omitted, the standard SUPPLY_POD_CASCADE_TYPES default is used (2 × Scrap + 1 × Mineral + 1 × Comet + 1 × Magnetar). kind: 'boss' encounters spawn two cascades with an ~80 px jitter; kind: 'mini' encounters spawn one.
Shipped per-boss cascade compositions:
| Boss | Cascade ring |
|---|---|
| Awakened Mech | drone_wreck, drone_wreck, mineral_vein, magnetar_pulse, scrap_pile |
| Digbot Swarm | (default) |
| Reactor Core | comet_fragment, comet_fragment, mineral_vein, magnetar_pulse, scrap_pile |
| Hive Queen | scrap_pile, scrap_pile, scrap_pile, mineral_vein, magnetar_pulse |
Step 8: Arena
BossDef.arena describes the fight room:
| Field | Type | Meaning |
|---|---|---|
shape | 'circle' | 'rect' | Circular or rectangular arena. |
size | number | { w, h } | Circle: radius. Rect: width × height. |
closingDuration | number (seconds) | How long the arena takes to close around the player at encounter start. |
terrain | TerrainPatternId (optional) | Destructible terrain pattern applied at encounter start. Defaults to 'open'. |
Note: arena.size here is the closing-circle size — the dynamic shrinking room used by the legacy bossRoom path. Sealed-arena boss levels (_levelKind === 'mini_boss' or 'boss') use a fixed square room driven by SEALED_ARENA_HALF_SIZE = 1400 (footprint 2800 × 2800 world units) and SEALED_ARENA_WALL_THICKNESS = 2000 from data/level-progression.ts; the boss’s own arena block is still honored for ability geometry (cardinal / ring / edge points are derived from these values via BossArena helpers in engine/boss/arena.ts).
Pillar / hazard patterns are layered in via terrain (e.g. pillar_ring for Hive Queen, corridor for Reactor Core, hazard_pads for Awakened Mech). They are applied by applyTerrainPattern(def.arena.terrain, arena, world) in spawnBoss.
Reference arenas:
| Boss | Shape | Size | Closing | Terrain |
|---|---|---|---|---|
| Awakened Mech | circle | r = 380 | 8 s | hazard_pads |
| Digbot Swarm | circle | r = 450 | 8 s | open |
| Reactor Core | rect | 800 × 400 | 8 s | corridor |
| Hive Queen | circle | r = 400 | 8 s | pillar_ring |
BossDef.backgroundLoop (optional) attaches a pre-baked H.264 loop rendered behind the arena. Spec target: 1280 × 720, 5–10 s seamless loop, ~5–15 MB, audio stripped, hosted under public/boss-bg/. No currently shipped boss sets this.
Step 9: Custom abilities or AI
If an existing pattern or AI behavior does not fit, the work moves out of data/ and into the engine.
| Custom case | Where it goes |
|---|---|
| New ability pattern | Add a new value to PatternId in engine/abilities/index.ts. Implement fire<Name>, optional tick<Name> (for sustained patterns), and optional execute<Name> (for telegraphed payloads) in engine/abilities/patterns.ts. Wire the switch cases in firePattern and tickSustained. Patterns crash on missing required params (loud, not silent) and call the VFX kit for telegraph + impact visuals. |
| New AI behavior | Register a new behavior string in engine/enemies/behaviors.ts and extend mapAiIdToBehavior in engine/boss/encounter.ts so the new aiId resolves. Unknown aiIds crash at spawn — there is no silent fallback. |
| New affix | Add the affix def under data/affixes/, implement runtime in engine/affixes/runtime.ts. The respawn_as and shielded_respawn affixes are the reference shapes for body-swapping and anchor-respawn flows. |
| Lifecycle hooks | BossDef exposes setupVfx, onBodyDeath, onDeath, onUpdate, onPhaseChanged. All are pure side-effect (no return value); they mutate the world and the shared VFX kit. |
The shared VFX kit instance is returned by getSharedBossVfxKit() so out-of-module hooks (such as the respawn_as runtime) can re-bind layers without creating a second kit.
Step 10: Validate
Verification checklist after wiring a new boss:
| Check | What to confirm |
|---|---|
| Registry | BOSS_DEFS[yourId] resolves at module load. pickRandomBossId(kind) returns your id for the matching pool. |
| Spawn | spawnBoss(yourId, game, world, kind) materializes the roster at the expected positions and stamps game._activeBossDefId. |
| HP bar | Anchor body’s isBoss is true; bar shows the boss displayName; total bar pool sums per-body hp * hpMult. |
| Scaling | Per-body hpMax = entry.hp * kindMult * depthMult matches the level you’re testing at. Damage scales via enemy.damageMult. |
| Phase transitions | Crossing each thresholds[i] swaps to nextTypes[i] (sprite changes), replaces abilities with nextAbilityIds[i], and re-applies nextAffixIds[i]. onPhaseChanged fires after the swap. |
| Abilities | Each ability fires on its declared cooldown; telegraph durations match params; sustained patterns hold the cooldown gate until sustainMs expires. |
| Spawn profile | Pressure adds appear at the configured triggers and positions. phase-keyed waves fire only when the anchor crosses into the matching phase index. |
| Rewards | On win the boss_kill signal carries the def’s xp, currency, and id. The supply pod cascade ring spawns at the kill anchor with the configured prop ids. extraRewardPicks extra level-up picks are offered. |
| Teardown | All sharing/anchor bodies cull on win/loss; ability bullets clear on win/level-exit (persist on player death); terrain pattern is cleared; clearBossVfxLayers() drops VFX; game.bossArena, game.bossSpawnProfile, game.bossEncounterTime, game._activeBossDefId all reset. |
| No console errors | Unknown aiId, missing ability id, missing affix id, missing enemy type id, or missing pattern params all crash loud — none should appear in a passing test. |