How to design a new ability pattern

Step-by-step guide for AI designers adding a new boss/enemy ability. Ability patterns are timed attack templates dispatched from engine/abilities/index.ts and implemented in engine/abilities/patterns.ts. An AbilityDef in data/abilities.ts binds a PatternId to tuned params and gets wired onto a boss via the boss roster’s abilityIds.

Read this in order. Most of the time you do not need a new pattern — step 1 covers reuse, which is the default answer.

1. Reuse before you build

The nine existing patterns cover almost every boss telegraph/projectile/AOE motif. Before adding a new PatternId, confirm none of these fit your design (even with retuned params):

PatternIdShapeSingle-shot or sustainedTypical use
radial_burstN bullets in 360° from casterSingle-shot (telegraph then fire)“Boss spins, fires omnidirectional spray”
aimed_volleyN bullets fanned at locked player positionSingle-shotMortar / sniper / shotgun shots — marco_aimed_volley
cone_slamWedge of chunky bullets in front of casterSingle-shotMelee-range slam, charge follow-up
telegraphed_circlesM painted ground circles detonate after windupSingle-shotPierre’s signature — pierre_telegraphed_circles
beam_sweepRotating laser arc from casterSustained (sweep duration)“Rotating death laser”
spiral_vortexRotating Touhou-style emit ringSustained (no telegraph; ramps visually)Sustained bullet hell
wall_sweepWall of bullets crosses arena with N safe gapsSingle-shotDodge-the-wall mechanic
spawn_telegraphPainted circles spawn enemies on detonationSingle-shotHive Queen hatch — generic_spawn_call
death_beamSingle arena-bisecting kill beamSustained (after telegraph)Phase finale / “do not get hit”

Reuse path: add a new entry to ABILITY_DEFS in data/abilities.ts keyed {boss}_{flavor} (e.g. marco_aimed_volley). Tune cooldown, startDelay, and params only. No engine changes needed. Boss-specific tuning is the whole point of the naming convention — do not mutate an existing AbilityDef for a new boss.

If retuning genuinely cannot express your design (e.g. you need a new motion curve, a new damage application model, or a new geometric shape), continue to step 2.

2. Adding a brand-new pattern

A new pattern is a three-file change:

  1. engine/abilities/index.ts — extend the PatternId union with your new id (e.g. 'gravity_well'). This is the single source of truth for the pattern enum.
  2. engine/abilities/patterns.ts — implement the pattern. At minimum:
    • A fireFoo(host, instance, def) fn that sets state.phase = 'telegraph' (or 'sustain' if you skip the telegraph like spiral_vortex does), seeds any per-fire state (locked player position, chosen circle points, etc.), and paints telegraph VFX via the lazy vfx() kit.
    • If single-shot: an executeFoo(host, instance, def, world) fn that the telegraph-elapsed branch in tickSustained calls. This is where bullets get pushed onto world.enemyBullets via spawnBullet(...) and impact VFX fires.
    • If sustained: a tickFoo(host, instance, def, dt, world) fn called every frame from the sustain branch of tickSustained. Decrement state.sustain each frame; when it reaches 0 the dispatcher transitions back to idle.
    • Add your case to the firePattern switch.
    • Add your case to the tickSustained telegraph-elapsed switch (and sustain switch if sustained).
  3. data/abilities.ts — add at least one AbilityDef exercising the new pattern. Keep params explicit; do not rely on optional fallbacks from numOpt/strOpt for boss-tuned values.

Crash on bad data. Use num(params, 'foo') for required params — it throws if the param is missing or wrong-typed. Only use numOpt(params, 'foo', fallback) for genuinely optional tuning. This is the codebase rule (CLAUDE.md: “Crash on bad data, no silent fallbacks”) and it applies hard inside patterns.

Arena guard. Every pattern fn calls const arena = getArena(); if (!arena) return; before doing arena-relative work. Patterns attached to non-boss enemies must no-op rather than crash so test mode can probe abilities outside boss encounters.

Bullet bookkeeping. Always use spawnBullet(...) — it sets _cullOutsideArena: true and _abilityBullet: true which the bridge’s bullet update loop reads to despawn cleanly. Do not push raw bullet objects into world.enemyBullets.

3. AbilityDef fields

Defined in engine/abilities/index.ts. Every ability def has exactly four authoring concerns:

FieldTypeMeaning
idstringGlobally unique. Collisions throw at registerAbility. Convention: {boss}_{flavor} or generic_{flavor}.
patternPatternIdOne of the nine names above (or your newly added one).
cooldownnumber (seconds)Seconds idle between fires. The clock starts when the telegraph+sustain phase ends, not when fire is invoked. This makes sustained patterns behave like designers expect — a 5s cooldown means 5s after the beam ends, not 5s after it started.
startDelaynumber? (seconds)Initial warm-up before the first fire. Defaults to 0. Used to stagger multi-ability bosses so they don’t all telegraph on tick zero.
paramsRecord<string, AbilityParamValue>Pattern-specific tuning. `AbilityParamValue = number

The params bag is intentionally untyped at the record level; each pattern fn coerces its expected keys via num/str/strArr. New patterns should keep this discipline — don’t introduce shared param shapes across patterns, each owns its own keys.

4. Telegraph timing convention

Telegraphs give the player a chance to read and react. Stick to the existing conventions unless the design calls for a deliberate exception:

PatterntelegraphMs defaultNotes
radial_burst1000Short — caster bloom is the read.
aimed_volley800Short — line points at locked player position.
cone_slam1200Medium — wedge needs to be readable from outside.
telegraphed_circles1500Canonical AOE windup. Pierre uses 1500; matches Diablo/MMO conventions.
beam_sweep1000Charge VFX + thin start-angle line.
wall_sweep2000Long — wall geometry needs to be parseable before it crosses.
spawn_telegraph1500Matches telegraphed_circles — same 1.5s “ground paint” feel.
death_beam3000Long — finale beam needs maximum reaction time + screen tint pulse.
spiral_vortexn/aNo telegraph; ramps via spiralSparkBurst VFX.

Rule: if you add a circle-on-ground motif, default to 1500 ms. If you add a player-instakill, default to 3000 ms. Otherwise pick from the table by similarity.

5. Color convention

Each pattern picks a base color expressed in params.bulletColor (or equivalent — telegraphed_circles uses bulletColor for both the circle paint and the impact shockwave). Conventions, by boss:

Boss / categoryBase colorHexPattern example
Pierre (Junkrat Captain)rust-orange#cc5522pierre_telegraphed_circles
Marco (Junkrat Captain)ember-red#ff4422marco_aimed_volley
Generic minor radialwarning orange#ff8800generic_minor_radial
Spawn telegraphhatch-green#88ff88generic_spawn_call (color hardcoded in fn)
Beam sweep defaulthot pink#ff0066
Death beam defaultpure red#ff0000
Spiral vortex defaultviolet#cc66ff
Wall sweep defaultcrimson#ff0044

When authoring a new boss-tuned AbilityDef, set bulletColor explicitly — do not rely on the pattern’s hex fallback. Boss color identity is part of readability, and a third Junkrat-Captain ability that defaults to #ff5500 instead of staying inside Pierre’s #cc5522 family is a regression.

If your boss owns multiple abilities, pick one base color and use small saturation/value shifts to differentiate (ember-yellow pulse on detonate, oil-black smoke on impact — see Pierre’s spec note in data/abilities.ts). Don’t introduce a new hue per ability.

6. Use BossArena helpers — no hard-coded coordinates

Patterns that place anything on the arena (circles, wall start, beam length) must go through the BossArena API exposed by getArena(). See the boss-arena-positions concept page for the full helper surface. Inside patterns you’ll typically use:

HelperUse
arena.diagonal()Max travel distance for radial / aimed bullets.
arena.radius()Half-extent — used by beam_sweep for beam length and wall_sweep for wall span.
arena.cx, arena.cyArena center; used by wall_sweep to anchor wall geometry.
arena.cardinalPoints(t)N,S,E,W points at fractional radius. Used by spawn_telegraph positions: 'cardinal'.
arena.ringPoints(count, t)count evenly-spaced points on a ring at fractional radius. Used by arenaPositions: 'ring'.
arena.randomPoint()Uniform random point inside arena. Default for arenaPositions: 'random'.

pickArenaPositions(arena, mode, count) in patterns.ts is the single switchboard for “I need N points on the arena.” Reuse it — do not roll your own coordinate selection. Hard-coded (x, y) pairs in a pattern fn are a bug.

7. Wiring an ability onto a boss

Once your AbilityDef exists in data/abilities.ts (or is registered via registerAbility from a boss data file), the boss picks it up by adding its id to the boss roster entry’s abilityIds field. The bridge constructs an AbilityInstance per id when the boss spawns, and tickAllAbilities(host, dt, world) fires each frame.

Roster wiring is a one-line change once the def exists. Example pattern (see the boss roster authoring guide for the full surrounding shape):

{
  id: 'pierre',
  // … other fields …
  abilityIds: ['pierre_telegraphed_circles'],
}

The id must be a string that resolves through getAbility — unknown ids throw at instance construction, so typos crash loud at boss spawn (not silently).

If a boss has multiple abilities, list them in order of priority by startDelay (the lowest startDelay fires first). The bridge does not interleave or prioritize; cooldown + start-delay is the whole scheduler.

Verification checklist

Before opening the PR:

  • PatternId union extended (if new pattern).
  • firePattern switch + tickSustained switches both updated (if new pattern).
  • At least one AbilityDef in data/abilities.ts exercises the pattern.
  • bulletColor is set explicitly on every boss-tuned def — no defaulting.
  • Telegraph duration matches the table in step 4 (or is justified in the def’s comment).
  • All arena coordinates come from getArena() helpers; no literal (x, y) pairs.
  • num() used for required params; numOpt() only for genuinely optional tuning.
  • Arena guard (if (!arena) return;) in every pattern fn that touches arena geometry.
  • Boss roster entry’s abilityIds references the new def id.
  • npx tsc --noEmit && npx vitest run && npx vite build all pass.