How to design a new planet
A sequential recipe for adding a new planet to the Starship Survivors roster. Planets are the metagame’s primary content unit: each planet bundles a biome, an enemy set, a final boss, a 10-level mastering track, and at least one mission posting. Pick a planet on the hub swiper, accept a posting from the mission board, and the run pipeline assembles a RunDefinition keyed off PlanetDef.
Before you start
A planet is a PlanetDef record in the PLANETS map plus a matching PlanetTrackDef in PLANET_TRACKS. Two-thirds of the work is filling a stat block; one-third is registering the planet in the right three places so the hub swiper, mission board, and run assembler all see it.
Read this first; it decides whether the work is data-only or needs an engine change.
| Question | If yes | If no |
|---|---|---|
Does the planet reuse an existing biome (landing_site, sunrise_city, the_voidstar, obsidian_spire, delphi, old_earth)? | Data-only — pick a BiomeId and write the PlanetDef. | A new BiomeId must be added to the union in planet-config.ts, then wired into world-gen (terrain pools, level presets). |
Does the planet reuse an existing enemy set (bugs, bugs_mortar, bugs_shooter, bugs_charger, bugs_sniper, bugs_field, bugs_racer, bugs_mixed, bugs_heavy, city)? | Data-only — pick an EnemySetId. | Author the new enemy set first (see enemies). |
Does the planet’s boss already exist (pacemaker, first_lady, cenotaph, iron_throne, spire, grand_procession, ringmaster, foreman, apex)? | Data-only — assign boss field. | Author the boss first (see bosses). Omitting boss falls back to iron_throne. |
| Is the planet a standard “complete the level arc” content planet? | Data-only — leave isLeaderboard unset. | Set isLeaderboard: true (Voidstar, Speedway). UI swaps the progress bar for a leaderboard + tier rewards; engine wiring already exists. |
Does the planet need a behavior that doesn’t fit any PlanetDef knob (e.g. a brand-new metagame mode)? | Data-only is fine. | Engine wiring needed; see “Custom-element structure rule” at the end. |
Data-only is the default path. The existing 11 planets in PLANET_ORDER are all built from the same PlanetDef shape.
Step 1: Identity
Fill this table before writing any code. Every cell maps to a field downstream.
| Slot | Notes |
|---|---|
id | Numeric PlanetId literal — must be added to the PlanetId union type in planet-config.ts. Current ids: 3, 12, 21, 30, 31, 32, 33, 34, 35, 36, 37. Pick the next free number (38). |
name | UPPERCASE display name shown on the hub swiper (e.g. 'SOLARIS', 'NETWORK STATION'). |
image | Path under public/ to the planet sprite — '/planets/<Name>.png'. |
biome | One of the six BiomeId values; drives terrain style, shape pools, hub topology. |
enemySet | EnemySetId — chooses the archetype pool. bugs family vs city is the top-level fork. |
boss | BossId that spawns at the level-5 final encounter. Falls back to iron_throne if omitted. |
| Lore / faction theme | Informs the mission posting’s faction field but is not a typed field on PlanetDef — flavor only. |
Numeric PlanetIds match the display name on Supabase, on the hub screen, and in RunDefinition.context.planetId. Do not renumber existing planets — only assign new numbers.
Step 2: Run definition (the PlanetDef field table)
Append a new entry to the PLANETS map in src/starship-survivors/data/planet-config.ts keyed by your new numeric id. Every field below is required (the ? types are still expected for most planets — see baselines).
| Field | Type | Baseline | What it controls |
|---|---|---|---|
id | PlanetId | (per planet) | Numeric planet id; matches RunDefinition.context.planetId and Supabase. |
biome | BiomeId | landing_site | World-gen biome — terrain style, shape pools, hub topology. |
name | string | (per planet) | Display name on the hub swiper. UPPERCASE convention. |
image | string | (per planet) | Path under public/ to the planet sprite. |
buildingsAllowed | boolean | true | Whether buildings can be placed on this planet. Voidstar + Obsidian Spire = false. |
postProcessing | 'dark' | 'sunlit' | dark | Post-processing preset applied during runs. sunlit is used by Sunrise City, Solaris, Eden-5, Delphi. |
fogAlpha | number | 0.15 | Atmospheric fog noise overlay opacity. 0 = void (Voidstar), 0.78 = thick (Sunrise City). |
enemyCountMult | number | 1.0 | Enemy count multiplier. 2.0 = Voidstar double pressure. |
spawnGraceSeconds | number | 1 | Grace period before full spawner pressure (seconds). 0 = instant (Voidstar). |
shadowOffsetMult | number | 1.0 | Drop shadow Y-offset multiplier. 2.0 = Sunrise City (tall buildings, longer shadows). |
enemySet | EnemySetId | bugs | Enemy archetype + spawn pools. See enemy-set roster table below. |
isLeaderboard | boolean? | unset | Leaderboard planet — swaps challenges + progress bar for leaderboard + tier rewards. Voidstar + Speedway. |
levelPreset | LevelPresetId? | derelict_station | Level preset for hub/spoke generation, spawning, visual style. Falls back to derelict_station if omitted. |
boss | BossId? | iron_throne | Boss that spawns at level-5 T-0. Per-planet (not per-index), so the mapping is stable against PLANET_ORDER reorderings. |
destructibles.crates | number | 40 | Crate density (0–100 slider). 100 = max system density. |
destructibles.debris | number | 4–19 | Floating metal-debris cloud density (0–100). Tuned per planet for spatial variety. |
Per-level scaling, mission timer, vision config, spawn curves, and worldKnobs (enemyHpMult, enemyDamageMult, enemySpeedMult, rewardMult, rarityScale) live on RunDefinition — assembled at run-start from the planet, ship, posting, and metagame state. The planet does not own those knobs directly. To bias spawn pressure or rewards globally on a planet, use enemyCountMult + spawnGraceSeconds on PlanetDef; everything else flows through RunDefinition.
Run sequence is fixed, not per-planet: every normal-mode run is 5 levels (normal, normal, mini_boss, hard, boss) and every challenge-mode run is 10 levels (normal, normal, mini_boss, normal, normal, mini_boss, hard, hard, mini_boss, boss). Defined in RUN_LEVEL_SEQUENCE / CHALLENGE_LEVEL_SEQUENCE. Planets cannot override the level count or sequence — content variation comes from biome, enemy set, and boss.
Challenge mode unlocks per-planet after clearing the normal-mode final boss; the assembler doubles rewardMult at run-assembly time. Boss kind stat boosts are global, not per-planet: BOSS_KIND_HP_MULT_MINI = 4.0, BOSS_KIND_HP_MULT_BOSS = 8.0, BOSS_KIND_DAMAGE_MULT_MINI = 4.0, BOSS_KIND_DAMAGE_MULT_BOSS = 8.0.
Enemy set roster
enemySet is the second-biggest content lever after biome. Pick the set that matches the planet’s combat fantasy.
enemySet | Combat theme | Used by |
|---|---|---|
bugs | Default orb/charger/shooter/mortar mix | Landing Site, Voidstar |
city | Gunner / field / sniper / racer urban roster | Sunrise City |
bugs_mortar | Mortar pressure (high-arc indirect fire) | Solaris |
bugs_shooter | Shooter gauntlets (sustained ranged fire) | Speedway |
bugs_charger | Charger swarms (melee rush) | Eden-5 |
bugs_sniper | Sniper invaders (long-range single shot) | Old Earth |
bugs_field | Field-emitter rares (area denial) | Network Station |
bugs_racer | Racer swarms (fast skirmishers) | Delphi |
bugs_heavy | Mortar + shooter suppression mix | Desolation |
bugs_mixed | All-rare mix | Obelisk |
Existing planet snapshot
For comparison while tuning a new planet, the eleven shipping planets are:
id | name | biome | enemySet | boss | fogAlpha | enemyCountMult | spawnGraceSeconds | postProcessing | isLeaderboard |
|---|---|---|---|---|---|---|---|---|---|
| 12 | Landing Site | landing_site | bugs | pacemaker | 0.15 | 1.0 | 1 | dark | – |
| 21 | Sunrise City | sunrise_city | city | first_lady | 0.78 | 1.0 | 1 | sunlit | – |
| 3 | The Voidstar | the_voidstar | bugs | cenotaph | 0.0 | 2.0 | 0 | dark | true |
| 30 | Solaris | landing_site | bugs_mortar | iron_throne | 0.20 | 1.0 | 1 | sunlit | – |
| 31 | Speedway | landing_site | bugs_shooter | spire | 0.10 | 1.0 | 1 | dark | true |
| 32 | Eden-5 | landing_site | bugs_charger | grand_procession | 0.20 | 1.0 | 1 | sunlit | – |
| 33 | Old Earth | old_earth | bugs_sniper | ringmaster | 0.25 | 1.0 | 1 | dark | – |
| 34 | Network Station | landing_site | bugs_field | foreman | 0.15 | 1.0 | 1 | dark | – |
| 35 | Delphi | delphi | bugs_racer | apex | 0.15 | 1.0 | 1 | sunlit | – |
| 36 | Desolation | landing_site | bugs_heavy | – | 0.15 | 1.0 | 1 | dark | – |
| 37 | Obelisk | landing_site | bugs_mixed | – | 0.10 | 1.0 | 1 | dark | – |
Planet 99 (Obsidian Spire) is defined in PLANETS but not in PLANET_ORDER — data-only, not player-accessible. Use it as a template for unfinished planets you want to compile but not ship.
Step 3: Mastering / progression track
Every planet has a 10-level mastering track in PLANET_TRACKS (src/starship-survivors/data/planet-progression.ts). The track is generated by buildTrackDef(planetId), which combines three inputs:
| Input | Source | Purpose |
|---|---|---|
xpThresholds | BASE_THRESHOLDS × PLANET_XP_MULT[planetId] | Cumulative XP required to reach levels 2–10. |
baseRunXp | BASE_RUN_XP[planetId] | Base XP awarded for completing any run on this planet. |
rewards | buildRewards(planetId) reading from per-planet names map | 10 reward cards, one per level. |
XP thresholds
BASE_THRESHOLDS is shared across planets: [100, 250, 500, 800, 1200, 1700, 2300, 3000, 4000, 5500]. The per-planet PLANET_XP_MULT scales the whole curve. Voidstar doubles it; Sunrise City adds 50%; everything else is currently at 1.0 (placeholder).
planetId | PLANET_XP_MULT | Effective level-10 threshold |
|---|---|---|
| 12 (Landing Site) | 1.0 | 5,500 |
| 21 (Sunrise City) | 1.5 | 8,250 |
| 3 (Voidstar) | 2.0 | 11,000 |
| 30–37 (rest) | 1.0 (placeholder) | 5,500 |
baseRunXp is the per-completion XP grant — Landing Site = 10, Sunrise City = 15, Voidstar = 20, all others 10 (placeholder). XP from completing challenges (big chunks via XP bounty) stacks on top.
A new planet’s PLANET_XP_MULT entry and BASE_RUN_XP entry are required — buildTrackDef will throw on undefined lookups. Set both to 1.0 / 10 for a baseline-difficulty planet, then tune up if the planet is harder than Landing Site.
Reward track shape
The reward shape is fixed for all planets — every planet’s 10-level track has the same PlanetRewardType per level. Only the reward’s name string varies per planet, sourced from the names map inside buildRewards.
| Level | PlanetRewardType | Rarity tier | Card colour | What it does |
|---|---|---|---|---|
| 1 | custom_event | common | white (#ffffff) | Unlocks a planet-specific sub-event during runs |
| 2 | ship_common | common | white | Common signature ship |
| 3 | weapon_1 | uncommon | green (#50ff78) | Signature weapon #1 enters weapon-box pool |
| 4 | ship_uncommon | uncommon | green | Uncommon signature ship |
| 5 | alt_boss | rare | blue (#44aaff) | Alternate boss encounter |
| 6 | ship_rare | rare | blue | Rare signature ship |
| 7 | planet_master | epic | purple (#cc66ff) | Advanced planet tuning variant |
| 8 | ship_epic | epic | purple | Epic signature ship |
| 9 | weapon_2 | legendary | gold (#ffd228) | Signature weapon #2 enters weapon-box pool |
| 10 | global_buff | legendary | gold | Permanent stat buff applied on all planets |
Per-planet reward names live in the names map inside buildRewards(planetId). Append a new key:
| Field | Example (Solaris) | Notes |
|---|---|---|
event | 'Solar Event' | Used for level 1 custom_event card |
ship | 'TBD' | Repeated across ship rarity tiers 2/4/6/8 |
w1 | 'TBD Weapon' | Level 3 weapon card |
w2 | 'TBD Weapon' | Level 9 weapon card |
boss | 'Secret Boss' | Level 5 alt-boss card |
buff | 'Global Fire Rate Boost' | Level 10 permanent global stat buff |
Add the planet id to PLANET_TRACKS at module bottom: <id>: buildTrackDef(<id>). Skipping this step makes getPlanetTrack(planetId) throw at runtime when the player visits the planet.
placeholderId on each reward is an empty string by default — fill it later when the ship/weapon ids actually exist. Track helpers (getLevelForXp, getXpProgress, getOverallPct) work without filled placeholders.
Step 4: Mission posting
Every new planet typically gets at least one mission posting so the player can launch a run on it from the mission board. Mission postings are appended to MISSION_POSTINGS in src/starship-survivors/data/mission-postings.ts.
The posting must reference the new planet by planetId. Without a posting, pickTwoPostingsForPlanet(planetId, seed) will fall back to pickTwoPostings(seed) (which never returns this planet’s id), so the player can still reach the planet by tapping LAUNCH directly on the hub swiper, but the mission board will not advertise it.
For posting-specific authoring (faction, objective label, difficulty, payout line, extraction timer), see how-to-design-a-new-mission-posting. Key fields to set:
| Field | Purpose for a new planet |
|---|---|
planetId | Must match the new PlanetId literal. |
faction | Pick from the 10 MissionFaction values, or extend the union if the planet has a new in-universe faction. |
difficulty | Drives extractionTimerSeconds via EXTRACTION_TIMER_BY_DIFFICULTY. Difficulty also signals reward tier to the player. |
objectiveLabel | One of Explore, Find, Protect, Battle. Each maps to enemyCountMult + weaponBoxCount tuning. |
objectiveBlurb | One-line in-universe task copy, positively framed. |
Postings stack — multiple postings per planet are allowed. The mission board will pick deterministically between them via the seed.
Step 5: PLANET_ORDER
PLANET_ORDER is the ordered array that drives the hub planet swiper. Index = swiper position. Players see planets in this exact order when swiping left/right on the hub.
Current order:
[12, 21, 3, 30, 31, 32, 33, 34, 35, 36, 37]
To add the new planet, append (or insert) its id into PLANET_ORDER. Position in the array is the user-facing reveal order — early planets are intro tier, later planets are end-game.
Planets defined in PLANETS but missing from PLANET_ORDER (e.g. planet 99 Obsidian Spire) compile and exist as data, but the hub swiper never shows them. Use this for staged rollouts: ship the data first, expose to players by adding to PLANET_ORDER later.
MISSION_POSTINGS is indexed against PLANET_ORDER positions (PLANET_ORDER[0] … PLANET_ORDER[10]), so re-ordering the array re-shuffles which posting belongs to which slot index. The current postings file uses bracket indexing — if you append the new planet to PLANET_ORDER, the existing posting indices remain stable.
Step 6: Register
Three modules must agree the planet exists. Skip any one and the planet either fails to compile, never appears on the hub, or crashes at runtime.
src/starship-survivors/data/planet-config.ts- Add the numeric id to the
PlanetIdunion type. - Add a
PlanetDefentry to thePLANETSmap. - Append (or insert) the id into
PLANET_ORDERfor hub visibility.
- Add the numeric id to the
src/starship-survivors/data/planet-progression.ts- Add a
PLANET_XP_MULT[<id>]entry. - Add a
BASE_RUN_XP[<id>]entry. - Add a per-planet
namesblock insidebuildRewards. - Add
<id>: buildTrackDef(<id>)to thePLANET_TRACKSmap.
- Add a
src/starship-survivors/data/mission-postings.ts- Append at least one
MissionPostingentry referencing the newplanetId.
- Append at least one
Asset:
public/planets/<Name>.png— planet swiper sprite. The image path is whatever you set inPlanetDef.image. Filename casing and spaces must match exactly.
The hub swiper, mission board, run assembler, and progression UI all read from these four sources. There is no central registry beyond the literals — adding the planet to all four is what makes it real.
Step 7: Validate
Checklist after registering. Run through every item before considering the planet shipped.
| Check | Expected |
|---|---|
| Hub swiper | Planet appears at its PLANET_ORDER position with the right sprite + display name. |
| Mission board | At least one posting for the new planet shows up; pickTwoPostingsForPlanet(<id>) returns it as card A. |
| LAUNCH | Tapping LAUNCH on the new planet (or accepting a posting) starts a run with RunDefinition.context.planetId === <id>. |
| Biome | World-gen uses the biome from PlanetDef.biome — terrain style matches expectation. |
| Enemy set | Enemies that spawn match the chosen enemySet. |
| Boss | Level-5 final encounter spawns the boss from PlanetDef.boss (falls back to iron_throne if omitted). |
| Mini-boss | Level-3 mini-boss arena seals correctly (SEALED_ARENA_HALF_SIZE = 1400, SEALED_ARENA_WALL_THICKNESS = 2000). |
| Mastering track | Hub track UI shows 10 levels with the correct rarity colours; getXpProgress(<id>, xp) returns sensible values. |
| XP accrual | Completing a run grants baseRunXp; challenge completions tick mastering XP. |
| Reward unlocks | Hitting each XP threshold unlocks the corresponding level’s reward card. |
| Fog + post-processing | fogAlpha and postProcessing render as authored. |
| Destructibles | Crate + debris density feel right per the destructibles sliders. |
| Spawn pressure | enemyCountMult and spawnGraceSeconds produce the intended early-game feel. |
| Leaderboard | If isLeaderboard: true, the planet shows a leaderboard panel instead of the progress bar. |
| Build | tsc clean, vitest passes, no console errors, no Sentry diagnostics on planet load. |
Custom-element structure rule
PlanetDef is intentionally a small, knob-bounded shape. New planet content (different enemies, bosses, biomes, sub-events, mission flavor) is data-only — extend the existing fields or pick new BiomeId / EnemySetId / BossId values. New planet behavior requires engine wiring.
The leaderboard planet pattern is the existing example of a planet-driven mode switch: isLeaderboard: true on PlanetDef is read by hub UI to swap the progress-bar block for a leaderboard + tier-rewards block, and by run-results UI to award leaderboard tier rewards instead of mastering XP. Voidstar (id: 3) and Speedway (id: 31) are the two existing leaderboard planets. Both compile from the same PlanetDef shape — the only data difference is the boolean flag — but the surrounding UI and rewards engine treat them differently because of bespoke conditionals on that flag.
If you want a new metagame mode that doesn’t fit any existing PlanetDef knob (e.g. a tower-defense planet, a daily-rotating planet, a co-op planet), the engine work happens before the data. Two layers must change in lockstep:
src/starship-survivors/data/planet-config.ts— add a new boolean or string field toPlanetDef(e.g.isTowerDefense: booleanormode: 'standard' | 'tower_defense' | 'leaderboard'). All existing planets compile-error until the field is filled in (or made optional with?).- Engine + UI consumers — add
if (planet.isTowerDefense) { ... }branches in the hub screen, the mission board (or skip it for that planet), the run assembler (assembleRunService), the engine bridge (engine/bridge.ts), and the run-results screen. Each branch needs its own UI block, run pipeline path, and reward path.
Match the leaderboard-planet pattern: a single boolean on PlanetDef, and every consumer reads the flag and branches. Do not add new pipeline state on RunDefinition for planet-mode dispatch — keep mode selection on PlanetDef, and let the engine read it at run assembly time.
Brand-new gameplay primitives that aren’t planet-mode-shaped (a new world-gen system, a new spawn pipeline, a new economy lane) belong in their own data file + engine module, not on PlanetDef. PlanetDef is the surface that picks among existing primitives — not the place to define them.