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.

TerrainPatternIdWhat it doesUsed by
openNothing. Empty arena.Default; non-terrain bosses
pillar_ring6 destructible pillars in a ring at 65% radiusHive Queen
pillar_cross4 pillars in a + at the four cardinals, 55% radiusDoomsayer
hazard_pads4 damaging floor pads in a ring at 50% radiusAwakened Mech (phase 3)
corridorTwo long walls along the arena’s long axis, 4 pillars each, funneling movement into the middleReactor Core (rect 800×400)
gauntletTwo staggered rows of 3 short walls, scattered cover that breaks sightlinesJunkrat 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 here

Then 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.

HelperReturnsUse for
arena.cx, arena.cyArena center (numbers)Anchoring custom math
arena.bounds(){ minX, minY, maxX, maxY } of the target footprintArbitrary rect layouts (corridor / gauntlet do this)
arena.ringPoints(count, fracOfRadius)count points evenly around a ring at fracOfRadius * targetRadiusRings of pillars or hazards
arena.cardinalPoints(fracOfRadius)4 points E/S/W/N at fracOfRadius * targetRadiusCross / plus / cardinal layouts
arena.edgePoints(count)count points on the arena boundaryWall-hugging layouts
arena.radius()Target radius (for circle) or min half-extent (for rect)Custom radial math
arena.diagonal()Arena diagonal lengthSpan-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:

ConstantValueMeaning
PILLAR_HP300Tuned so 2–3 player weapon volleys clear a pillar.
PILLAR_RADIUS30Small enough to weave around, big enough to read clearly.
HAZARD_DPS18Chip damage that punishes camping but won’t insta-kill.
HAZARD_RADIUS70Hazard 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 TerrainPatternId added to the union
  • New entry in TERRAIN_PATTERNS with matching id
  • positionFn uses only BossArena helpers — 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 terrain to the new ID