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):
| PatternId | Shape | Single-shot or sustained | Typical use |
|---|---|---|---|
radial_burst | N bullets in 360° from caster | Single-shot (telegraph then fire) | “Boss spins, fires omnidirectional spray” |
aimed_volley | N bullets fanned at locked player position | Single-shot | Mortar / sniper / shotgun shots — marco_aimed_volley |
cone_slam | Wedge of chunky bullets in front of caster | Single-shot | Melee-range slam, charge follow-up |
telegraphed_circles | M painted ground circles detonate after windup | Single-shot | Pierre’s signature — pierre_telegraphed_circles |
beam_sweep | Rotating laser arc from caster | Sustained (sweep duration) | “Rotating death laser” |
spiral_vortex | Rotating Touhou-style emit ring | Sustained (no telegraph; ramps visually) | Sustained bullet hell |
wall_sweep | Wall of bullets crosses arena with N safe gaps | Single-shot | Dodge-the-wall mechanic |
spawn_telegraph | Painted circles spawn enemies on detonation | Single-shot | Hive Queen hatch — generic_spawn_call |
death_beam | Single arena-bisecting kill beam | Sustained (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:
engine/abilities/index.ts— extend thePatternIdunion with your new id (e.g.'gravity_well'). This is the single source of truth for the pattern enum.engine/abilities/patterns.ts— implement the pattern. At minimum:- A
fireFoo(host, instance, def)fn that setsstate.phase = 'telegraph'(or'sustain'if you skip the telegraph likespiral_vortexdoes), seeds any per-fire state (locked player position, chosen circle points, etc.), and paints telegraph VFX via the lazyvfx()kit. - If single-shot: an
executeFoo(host, instance, def, world)fn that the telegraph-elapsed branch intickSustainedcalls. This is where bullets get pushed ontoworld.enemyBulletsviaspawnBullet(...)and impact VFX fires. - If sustained: a
tickFoo(host, instance, def, dt, world)fn called every frame from the sustain branch oftickSustained. Decrementstate.sustaineach frame; when it reaches 0 the dispatcher transitions back to idle. - Add your case to the
firePatternswitch. - Add your case to the
tickSustainedtelegraph-elapsed switch (and sustain switch if sustained).
- A
data/abilities.ts— add at least oneAbilityDefexercising the new pattern. Keep params explicit; do not rely on optional fallbacks fromnumOpt/strOptfor 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:
| Field | Type | Meaning |
|---|---|---|
id | string | Globally unique. Collisions throw at registerAbility. Convention: {boss}_{flavor} or generic_{flavor}. |
pattern | PatternId | One of the nine names above (or your newly added one). |
cooldown | number (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. |
startDelay | number? (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. |
params | Record<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:
| Pattern | telegraphMs default | Notes |
|---|---|---|
radial_burst | 1000 | Short — caster bloom is the read. |
aimed_volley | 800 | Short — line points at locked player position. |
cone_slam | 1200 | Medium — wedge needs to be readable from outside. |
telegraphed_circles | 1500 | Canonical AOE windup. Pierre uses 1500; matches Diablo/MMO conventions. |
beam_sweep | 1000 | Charge VFX + thin start-angle line. |
wall_sweep | 2000 | Long — wall geometry needs to be parseable before it crosses. |
spawn_telegraph | 1500 | Matches telegraphed_circles — same 1.5s “ground paint” feel. |
death_beam | 3000 | Long — finale beam needs maximum reaction time + screen tint pulse. |
spiral_vortex | n/a | No 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 / category | Base color | Hex | Pattern example |
|---|---|---|---|
| Pierre (Junkrat Captain) | rust-orange | #cc5522 | pierre_telegraphed_circles |
| Marco (Junkrat Captain) | ember-red | #ff4422 | marco_aimed_volley |
| Generic minor radial | warning orange | #ff8800 | generic_minor_radial |
| Spawn telegraph | hatch-green | #88ff88 | generic_spawn_call (color hardcoded in fn) |
| Beam sweep default | hot pink | #ff0066 | — |
| Death beam default | pure red | #ff0000 | — |
| Spiral vortex default | violet | #cc66ff | — |
| Wall sweep default | crimson | #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:
| Helper | Use |
|---|---|
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.cy | Arena 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:
-
PatternIdunion extended (if new pattern). -
firePatternswitch +tickSustainedswitches both updated (if new pattern). - At least one
AbilityDefindata/abilities.tsexercises the pattern. -
bulletColoris 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
abilityIdsreferences the new def id. -
npx tsc --noEmit && npx vitest run && npx vite buildall pass.