How to design a new biome
Step-by-step guide for adding a new themed biome (landing_site, sunrise_city, the_voidstar, etc.) to the world generator.
Before you start
A biome is a themed visual + terrain + spawn assembly. It bundles together a terrain shape mix, a hub-and-spoke topology, a parallax recipe (background art layers), a palette preference list, a nebula archetype pool, and a flag for which planets can reference it. Multiple planet configs can point at the same biome — for example landing_site is shared by LANDING SITE, SOLARIS, SPEEDWAY, EDEN-5, NETWORK STATION, OBELISK, and DESOLATION. Only sunrise_city, the_voidstar, obsidian_spire, delphi, and old_earth are bespoke single-planet biomes today.
A biome does NOT pick the boss — the planet config does (PlanetDef.boss). The boss’s own arena definition picks the terrain pattern that fills the sealed arena. Biome terrain only governs the wilds outside the arena footprint.
Adding a new biome = one BiomeId entry + one BIOMES entry + one parallax recipe + one palette-preference row + one nebula-pool row + one or more planet references.
Step 1: Identity
Pick the load-bearing identity values up front.
| Field | What it controls | Reference values |
|---|---|---|
BiomeId | Stable string id used by every subsystem | landing_site, sunrise_city, the_voidstar, obsidian_spire, delphi, old_earth |
name | Display label in dev HUDs and tab pickers | Landing Site, Sunrise City, The Voidstar |
levelRadius | Outer ring of the playable disc, world units | 2500 (every shipped biome) |
postProcessing mode | Mastering preset on the planet, not the biome | dark, sunlit |
fogAlpha | Per-planet atmospheric fog overlay opacity | 0.00 (Voidstar) → 0.78 (Sunrise City) |
shadowOffsetMult | Drop-shadow Y multiplier on the planet | 1.0 default; 2.0 for tall city buildings |
The BiomeId type union lives in data/planet-config.ts. Add your id there first — it forces the rest of the system to compile-error until every keyed table is filled.
Step 2: Terrain mix
A biome’s terrainMix is three weighted shape pools: centers (large cluster anchors), mediums (the ring around each center), smalls (the outer scatter). The cluster generator picks one shape from each pool per cluster.
Available shape primitives from data/terrain-shapes.ts:
| Shape id | Type | Base radius (world units) | Scale range | Notes |
|---|---|---|---|---|
asteroid_sm | polygon | 75 | 1.0 — 1.5 | Small rock, 5 verts, jitter 0.20 |
asteroid_md | polygon | 85 | 1.0 — 1.6 | Mid rock, 6 verts, jitter 0.18 |
asteroid_lg | polygon | 98 | 1.0 — 1.6 | Large rock, 7 verts, jitter 0.15 |
asteroid_xl | polygon | 100 | 1.0 — 1.8 | Boulder, 8 verts, jitter 0.12 |
rock_shard | polygon | 60 | 1.0 — 1.3 | Spiky shard, 5 verts, jitter 0.25 |
building_1x1 | sprite | 277 | 1.0 | City block, small footprint |
building_2x1 | sprite | 322 | 1.0 | City block, wide footprint |
building_1x2 | sprite | 284 | 1.0 | City block, tall footprint |
building_2x3 | sprite | 385 | 1.0 | City block, large footprint |
pillar_sm | sprite | 576 | 1.0 | Obsidian pillar, small |
pillar_md | sprite | 768 | 1.0 | Obsidian pillar, medium |
pillar_lg | sprite | 960 | 1.0 | Obsidian pillar, large |
Reference mixes shipped today:
| Biome | Center pool weights | Notes |
|---|---|---|
landing_site | asteroid_xl 60, asteroid_lg 40 | Pure asteroid field |
sunrise_city | building_2x3 50, building_2x1 30, building_1x2 20 | Buildings only, scatterOnly: true |
the_voidstar | asteroid_xl 18, asteroid_lg 12, pillar_md 70 | Asteroids reduced to 30 % weight, pillars dominate |
obsidian_spire | pillar_md 60, pillar_sm 40 | Pure pillar field |
Each biome also carries density knobs:
| Field | Range | Reference values |
|---|---|---|
terrainDensity | Cluster-fill coverage target | 0.55 (Voidstar) → 0.85 (Landing Site) |
terrainIntensity | Asteroid density multiplier | 0.50 (Obsidian) → 0.70 (Landing Site) |
Step 3: Nebula archetype
Background nebula is rendered by the WebGL nebula shader behind every parallax recipe. Biomes do not own a single archetype — they own a pool of archetype indexes, and the engine picks one per run from the pool.
BIOME_BACKGROUNDS in data/nebula-archetypes.ts maps biomeId → archetype index[]. Every shipped biome currently points at the shared SMOOTH_NEBULA_POOL, which is the subset of the 100 archetypes that pass isSmoothArchetype:
| Filter | Value |
|---|---|
nt (noise type) | 0 (classic FBM) |
warp | ≤ 0.7 |
density | 0.4 — 0.65 |
sat (saturation) | ≤ 1.0 |
SURF_MODE | 0 (space, not boss-arena surface) |
For planet-locked looks, register a one-off archetype in _PLANET_<ID>_ARCH and push it into the ARCHETYPES array, then add the index to PLANET_ARCHETYPE_IDX. The renderer prefers this per-planet index over the biome pool.
Archetype knobs available (see Archetype interface):
| Field | What it controls |
|---|---|
pa, pb, pc, pd | Cosine palette parameters, vec3 each |
warp | Domain-warp strength, 0 flat → 3 chaotic |
density | Nebula fill, 0 sparse → 1 opaque |
speed | Animation drift speed |
thresh | Visibility threshold, higher = more void |
bg | Background base color [r,g,b] 0 — 1 |
shape | Near-layer star shape — sq, dot, dia, tri, hex, star5, spark, cross |
nf | Noise frequency, 0.3 smooth → 1.5 detailed |
nt | Noise type — 0 FBM, 1 ridged veins, 2 voronoi |
ec | Emission tint [r,g,b] 0 — 255 (star glow) |
sat, lum | Saturation and luminosity multipliers |
fg | Fog overlay intensity, 0 — 1 |
mode | Advanced render mode (0 classic, 1 enriched, 2 spiral, 3 blackhole, 4 horizon, 5 aurora, 6 warp tunnel, 7 cavern) |
spk | Sparkle / twinkle intensity, 0 — 1 |
ssa | Shooting-star base angle in radians (default ≈ 0.6) |
ssv | Shooting-star angle variance, 0 parallel → 1 chaotic |
Step 4: Zone classifier rules
Every world point is classified into one of five zone types by classifyZone() in engine/world/zone-classifier.ts. The biome does NOT pick the rules — it picks the topology knobs that the classifier uses.
| Zone type | When |
|---|---|
hub_dark | Inside a hub circle, illumination map says hub is dark |
hub_lit | Inside a hub circle, illumination map says hub is lit |
spoke_dark | Within spokeHalfWidth of a spoke center line, both end hubs dark |
spoke_lit | Within spokeHalfWidth of a spoke center line, at least one end hub lit |
wilds | Everywhere else (never illuminated) |
Hub topology knobs the biome supplies:
| Field | What it controls | Reference values |
|---|---|---|
hubMinDist | Ring spacing floor, world units | 400 — 500 (none for scatter-only) |
hubMaxDist | Ring spacing ceiling | 1000 — 1200 |
hubsPerRing | Hubs placed on each ring | 5 — 6 |
hubClearMin, hubClearMax | Hub clear-radius RNG range | 200 / 200 today |
spokeWidthMin, spokeWidthMax | Spoke corridor width RNG range | 150 / 150 today |
scatterOnly | Disables hub-and-spoke entirely | true for sunrise_city |
Priority: hubs win over spokes win over wilds — the classifier checks hubs first since they are smaller and more specific.
Step 5: Boss-arena terrain pattern default
Boss arenas are sealed squares that despawn all biome terrain inside the arena footprint and drop in a static terrain pattern from data/terrain-patterns.ts. The pattern is keyed PER-BOSS in the boss def (def.arena.terrain), not per-biome — so a biome cannot force a pattern, it can only inform which boss the planet picks. Patterns shipped today:
| Pattern id | Layout | Uses |
|---|---|---|
open | No terrain at all | Default fallback |
pillar_ring | 6 destructible pillars in a ring at 65 % arena radius | Hive Queen |
pillar_cross | 4 pillars in a + cross at 55 % radius | Doomsayer |
hazard_pads | 4 damaging floor pads in a ring at 50 % radius, 70 unit radius, 18 dps | Awakened Mech phase 3 |
corridor | Two walls of 4 pillars along the long axis at 55 % short offset | Reactor Core (rect 800×400) |
gauntlet | Two staggered rows of 3 pillars at 40 % short offset | Junkrat Captains |
Pillar specs are shared across patterns:
| Constant | Value |
|---|---|
PILLAR_HP | 300 |
PILLAR_RADIUS | 30 world units |
HAZARD_DPS | 18 damage / sec |
HAZARD_RADIUS | 70 world units |
If a new biome’s bosses need a new pattern (e.g. a maze, a spiral, asymmetric cover), add a TerrainPatternDef to TERRAIN_PATTERNS with a positionFn(arena) that emits world coordinates, then point a boss def at it.
Step 6: Surface FX
The Archetype interface reserves ten surface-FX flags — hm, wv, gl, ca, pr, ln, sm, sb, sp, ss — for heat shimmer, waves, lensing, chromatic aberration, prisms, lens flares, smoke, sub-bands, sparkle, shooting stars. As of the last verified commit, none of the 100 shipped archetypes set these flags — the comment in nebula-archetypes.ts reads “Surface FX flags … are preserved for future boss-arena rendering but not used in normal gameplay.”
For a new biome, leave these unset. Use spk, ssa, ssv and the mode knob (sparkle, shooting-star angle, advanced render mode) for sparkle / streak effects today.
Step 7: Register
A new biome touches six tables. Every one is keyed by the same BiomeId string.
| File | Edit |
|---|---|
data/planet-config.ts | Add to BiomeId union |
engine/world/generation.ts | Add BIOMES[<id>] BiomeConfig entry with terrainPool, terrainMix, density knobs, hub topology knobs |
engine/rendering/parallax/biome-recipes.ts | Add a recipe factory and register it in _recipeFactories |
engine/rendering/palette/palette-presets.ts | Add a BIOME_PREFERRED_PALETTES[<id>] entry (array of preset ids in preference order) |
data/nebula-archetypes.ts | Add a BIOME_BACKGROUNDS[<id>] entry (array of archetype indexes) |
data/planet-config.ts | Reference the biome from one or more PLANETS[<id>].biome slots |
Available palette preset ids (from palette-presets.ts): pure_grey, noir, ice_seed, voidmilk, deep_frost, cold_ember, amethyst_drift, rose_quartz, dusty_rose, mint_jade, seafoam, cotton_sky, periwinkle, peach_cream, lavender_haze.
A planet also needs an enemySet, levelPreset, boss, destructibles density, and a planet image — those are planet-level, not biome-level.
Step 8: Validate
Boot the playground (LevelTab or PlanetsTab), pick a planet that references the new biome, and confirm:
- Terrain pieces match the mix — centers are the large shapes, mediums and smalls fall off correctly.
- Hub-and-spoke topology emerges (unless
scatterOnlyis set) with the configured ring spacing and spoke width. - Parallax backdrop draws — back / mid / near / fg slots all render, nebula reads through at low back-slot opacity.
- Nebula archetype draws — color, density, shape, and animation match expectations.
- Palette preset matches the biome’s mood — preferred list is honored.
- Ambient drop-shadow Y-offset is correct (
shadowOffsetMulton the planet). - Fog overlay opacity is correct (
fogAlphaon the planet). - Boss arena replaces biome terrain cleanly when an encounter starts.
Custom-element rule
New biomes are data-only — every change lives in the six tables above. New terrain shape primitives, however, are engine work: they need a TerrainShapeDef entry in data/terrain-shapes.ts and (for sprite types) an asset registered with the renderer. New parallax layer kinds beyond starfield / silhouette stamp / atmosphere FBM / ground texture need new layer factories alongside createStarfieldLayer, createSilhouetteStampLayer, createAtmosphereFbmLayer, createGroundTextureLayer.