How to design a new terrain pattern
A terrain pattern is a static layout of destructible pillars and/or damaging hazard zones that drops into a boss arena on encounter start and clears on encounter end. Patterns shape the fight by forcing movement, breaking sightlines, or punishing camping. They are arena-agnostic — every position routes through BossArena helpers so the same pattern fits both circle and rect arenas without modification.
This guide walks through adding a seventh entry to the library.
1. Survey the existing six patterns
The full library lives in src/starship-survivors/data/terrain-patterns.ts. Before designing a new one, know what the existing ones cover so you don’t reinvent.
TerrainPatternId | What it does | Used by |
|---|---|---|
open | Nothing. Empty arena. | Default; non-terrain bosses |
pillar_ring | 6 destructible pillars in a ring at 65% radius | Hive Queen |
pillar_cross | 4 pillars in a + at the four cardinals, 55% radius | Doomsayer |
hazard_pads | 4 damaging floor pads in a ring at 50% radius | Awakened Mech (phase 3) |
corridor | Two long walls along the arena’s long axis, 4 pillars each, funneling movement into the middle | Reactor Core (rect 800×400) |
gauntlet | Two staggered rows of 3 short walls, scattered cover that breaks sightlines | Junkrat Captains |
Patterns are mostly boss-arena scenarios — they exist so a boss def can call out a terrain shape without owning the layout code.
2. Add the TerrainPatternId union member
In terrain-patterns.ts, extend the union:
export type TerrainPatternId =
| 'open'
| 'pillar_ring'
| 'pillar_cross'
| 'hazard_pads'
| 'corridor'
| 'gauntlet'
| 'your_new_pattern'; // <-- add hereThen add a new entry to TERRAIN_PATTERNS:
your_new_pattern: {
id: 'your_new_pattern',
destructibles: [
{
hp: PILLAR_HP,
radius: PILLAR_RADIUS,
positionFn: (arena) => arena.ringPoints(8, 0.7),
},
],
hazardZones: [
{
positionFn: (arena) => arena.cardinalPoints(0.4),
radius: HAZARD_RADIUS,
damagePerSec: HAZARD_DPS,
},
],
},destructibles is required (use [] for layouts with no pillars, like hazard_pads or open). hazardZones is optional — omit it entirely if the pattern is pillars-only.
3. Write the positionFn
Each pillar group and hazard group owns a positionFn:
positionFn: (arena: BossArena) => { x: number; y: number }[]The function runs once on encounter start with the live BossArena. It returns a flat list of world-space positions; one entry per pillar (or per hazard zone). The engine instantiates one destructible / one hazard per returned point.
Inside positionFn, never reference screen size, window dimensions, or world constants directly. Every coordinate must derive from the arena — that’s how the same pattern works for both a 400-radius circle arena and an 800×400 rect arena.
4. Use the arena helpers
BossArena (defined in src/starship-survivors/engine/boss/arena.ts, wrapping a live BossRoom) exposes the position primitives. These are the same helpers boss spawn-profiles use, so behavior stays consistent across arena shapes.
| Helper | Returns | Use for |
|---|---|---|
arena.cx, arena.cy | Arena center (numbers) | Anchoring custom math |
arena.bounds() | { minX, minY, maxX, maxY } of the target footprint | Arbitrary rect layouts (corridor / gauntlet do this) |
arena.ringPoints(count, fracOfRadius) | count points evenly around a ring at fracOfRadius * targetRadius | Rings of pillars or hazards |
arena.cardinalPoints(fracOfRadius) | 4 points E/S/W/N at fracOfRadius * targetRadius | Cross / plus / cardinal layouts |
arena.edgePoints(count) | count points on the arena boundary | Wall-hugging layouts |
arena.radius() | Target radius (for circle) or min half-extent (for rect) | Custom radial math |
arena.diagonal() | Arena diagonal length | Span-scaled custom math |
For rect-only patterns like corridor and gauntlet, compute halfW / halfH from arena.bounds() and decide horizontal vs vertical by which is longer:
const b = arena.bounds();
const halfW = (b.maxX - b.minX) / 2;
const halfH = (b.maxY - b.minY) / 2;
const horizontal = halfW >= halfH;This makes the pattern adapt automatically — if a future rect arena is taller than wide, the same code reorients.
5. Shared tuning constants
Reuse the four module-local constants at the top of terrain-patterns.ts rather than inlining numbers:
| Constant | Value | Meaning |
|---|---|---|
PILLAR_HP | 300 | Tuned so 2–3 player weapon volleys clear a pillar. |
PILLAR_RADIUS | 30 | Small enough to weave around, big enough to read clearly. |
HAZARD_DPS | 18 | Chip damage that punishes camping but won’t insta-kill. |
HAZARD_RADIUS | 70 | Hazard pad footprint. |
Only deviate from these if the pattern needs a deliberately tankier wall (e.g. a phase-locked barrier) or a deadlier hazard (e.g. an instant-shred floor). When you do deviate, add a sibling constant with a descriptive name (BARRIER_HP, LETHAL_HAZARD_DPS) — never inline a magic number.
6. Wire the pattern to a boss
Patterns are pulled in by the boss definition. In the boss def, set the terrain field to the new TerrainPatternId. The engine reads this at encounter start, looks up the entry in TERRAIN_PATTERNS, runs each positionFn against the live arena, and spawns the destructibles + hazard zones. On encounter end the engine clears them.
If multiple bosses want subtly different versions of the same shape, prefer separate pattern IDs (pillar_ring_dense, pillar_ring_outer) over parameterizing one pattern — it keeps each boss def declarative and grep-able.
Checklist
- New
TerrainPatternIdadded to the union - New entry in
TERRAIN_PATTERNSwith matchingid -
positionFnuses onlyBossArenahelpers — no screen/world constants - Pillar HP/radius and hazard DPS/radius use shared constants (or named siblings)
- Works on both circle and rect arenas (or explicitly documented as rect-only, like
corridor) - At least one boss def sets
terrainto the new ID