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.

QuestionIf yesIf 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.

SlotNotes
idNumeric 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).
nameUPPERCASE display name shown on the hub swiper (e.g. 'SOLARIS', 'NETWORK STATION').
imagePath under public/ to the planet sprite — '/planets/<Name>.png'.
biomeOne of the six BiomeId values; drives terrain style, shape pools, hub topology.
enemySetEnemySetId — chooses the archetype pool. bugs family vs city is the top-level fork.
bossBossId that spawns at the level-5 final encounter. Falls back to iron_throne if omitted.
Lore / faction themeInforms 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).

FieldTypeBaselineWhat it controls
idPlanetId(per planet)Numeric planet id; matches RunDefinition.context.planetId and Supabase.
biomeBiomeIdlanding_siteWorld-gen biome — terrain style, shape pools, hub topology.
namestring(per planet)Display name on the hub swiper. UPPERCASE convention.
imagestring(per planet)Path under public/ to the planet sprite.
buildingsAllowedbooleantrueWhether buildings can be placed on this planet. Voidstar + Obsidian Spire = false.
postProcessing'dark' | 'sunlit'darkPost-processing preset applied during runs. sunlit is used by Sunrise City, Solaris, Eden-5, Delphi.
fogAlphanumber0.15Atmospheric fog noise overlay opacity. 0 = void (Voidstar), 0.78 = thick (Sunrise City).
enemyCountMultnumber1.0Enemy count multiplier. 2.0 = Voidstar double pressure.
spawnGraceSecondsnumber1Grace period before full spawner pressure (seconds). 0 = instant (Voidstar).
shadowOffsetMultnumber1.0Drop shadow Y-offset multiplier. 2.0 = Sunrise City (tall buildings, longer shadows).
enemySetEnemySetIdbugsEnemy archetype + spawn pools. See enemy-set roster table below.
isLeaderboardboolean?unsetLeaderboard planet — swaps challenges + progress bar for leaderboard + tier rewards. Voidstar + Speedway.
levelPresetLevelPresetId?derelict_stationLevel preset for hub/spoke generation, spawning, visual style. Falls back to derelict_station if omitted.
bossBossId?iron_throneBoss that spawns at level-5 T-0. Per-planet (not per-index), so the mapping is stable against PLANET_ORDER reorderings.
destructibles.cratesnumber40Crate density (0–100 slider). 100 = max system density.
destructibles.debrisnumber4–19Floating 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.

enemySetCombat themeUsed by
bugsDefault orb/charger/shooter/mortar mixLanding Site, Voidstar
cityGunner / field / sniper / racer urban rosterSunrise City
bugs_mortarMortar pressure (high-arc indirect fire)Solaris
bugs_shooterShooter gauntlets (sustained ranged fire)Speedway
bugs_chargerCharger swarms (melee rush)Eden-5
bugs_sniperSniper invaders (long-range single shot)Old Earth
bugs_fieldField-emitter rares (area denial)Network Station
bugs_racerRacer swarms (fast skirmishers)Delphi
bugs_heavyMortar + shooter suppression mixDesolation
bugs_mixedAll-rare mixObelisk

Existing planet snapshot

For comparison while tuning a new planet, the eleven shipping planets are:

idnamebiomeenemySetbossfogAlphaenemyCountMultspawnGraceSecondspostProcessingisLeaderboard
12Landing Sitelanding_sitebugspacemaker0.151.01dark
21Sunrise Citysunrise_citycityfirst_lady0.781.01sunlit
3The Voidstarthe_voidstarbugscenotaph0.02.00darktrue
30Solarislanding_sitebugs_mortariron_throne0.201.01sunlit
31Speedwaylanding_sitebugs_shooterspire0.101.01darktrue
32Eden-5landing_sitebugs_chargergrand_procession0.201.01sunlit
33Old Earthold_earthbugs_sniperringmaster0.251.01dark
34Network Stationlanding_sitebugs_fieldforeman0.151.01dark
35Delphidelphibugs_racerapex0.151.01sunlit
36Desolationlanding_sitebugs_heavy0.151.01dark
37Obelisklanding_sitebugs_mixed0.101.01dark

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:

InputSourcePurpose
xpThresholdsBASE_THRESHOLDS × PLANET_XP_MULT[planetId]Cumulative XP required to reach levels 2–10.
baseRunXpBASE_RUN_XP[planetId]Base XP awarded for completing any run on this planet.
rewardsbuildRewards(planetId) reading from per-planet names map10 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).

planetIdPLANET_XP_MULTEffective level-10 threshold
12 (Landing Site)1.05,500
21 (Sunrise City)1.58,250
3 (Voidstar)2.011,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.

LevelPlanetRewardTypeRarity tierCard colourWhat it does
1custom_eventcommonwhite (#ffffff)Unlocks a planet-specific sub-event during runs
2ship_commoncommonwhiteCommon signature ship
3weapon_1uncommongreen (#50ff78)Signature weapon #1 enters weapon-box pool
4ship_uncommonuncommongreenUncommon signature ship
5alt_bossrareblue (#44aaff)Alternate boss encounter
6ship_rarerareblueRare signature ship
7planet_masterepicpurple (#cc66ff)Advanced planet tuning variant
8ship_epicepicpurpleEpic signature ship
9weapon_2legendarygold (#ffd228)Signature weapon #2 enters weapon-box pool
10global_bufflegendarygoldPermanent stat buff applied on all planets

Per-planet reward names live in the names map inside buildRewards(planetId). Append a new key:

FieldExample (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:

FieldPurpose for a new planet
planetIdMust match the new PlanetId literal.
factionPick from the 10 MissionFaction values, or extend the union if the planet has a new in-universe faction.
difficultyDrives extractionTimerSeconds via EXTRACTION_TIMER_BY_DIFFICULTY. Difficulty also signals reward tier to the player.
objectiveLabelOne of Explore, Find, Protect, Battle. Each maps to enemyCountMult + weaponBoxCount tuning.
objectiveBlurbOne-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.

  1. src/starship-survivors/data/planet-config.ts
    • Add the numeric id to the PlanetId union type.
    • Add a PlanetDef entry to the PLANETS map.
    • Append (or insert) the id into PLANET_ORDER for hub visibility.
  2. 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 names block inside buildRewards.
    • Add <id>: buildTrackDef(<id>) to the PLANET_TRACKS map.
  3. src/starship-survivors/data/mission-postings.ts
    • Append at least one MissionPosting entry referencing the new planetId.

Asset:

  • public/planets/<Name>.png — planet swiper sprite. The image path is whatever you set in PlanetDef.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.

CheckExpected
Hub swiperPlanet appears at its PLANET_ORDER position with the right sprite + display name.
Mission boardAt least one posting for the new planet shows up; pickTwoPostingsForPlanet(<id>) returns it as card A.
LAUNCHTapping LAUNCH on the new planet (or accepting a posting) starts a run with RunDefinition.context.planetId === <id>.
BiomeWorld-gen uses the biome from PlanetDef.biome — terrain style matches expectation.
Enemy setEnemies that spawn match the chosen enemySet.
BossLevel-5 final encounter spawns the boss from PlanetDef.boss (falls back to iron_throne if omitted).
Mini-bossLevel-3 mini-boss arena seals correctly (SEALED_ARENA_HALF_SIZE = 1400, SEALED_ARENA_WALL_THICKNESS = 2000).
Mastering trackHub track UI shows 10 levels with the correct rarity colours; getXpProgress(<id>, xp) returns sensible values.
XP accrualCompleting a run grants baseRunXp; challenge completions tick mastering XP.
Reward unlocksHitting each XP threshold unlocks the corresponding level’s reward card.
Fog + post-processingfogAlpha and postProcessing render as authored.
DestructiblesCrate + debris density feel right per the destructibles sliders.
Spawn pressureenemyCountMult and spawnGraceSeconds produce the intended early-game feel.
LeaderboardIf isLeaderboard: true, the planet shows a leaderboard panel instead of the progress bar.
Buildtsc 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:

  1. src/starship-survivors/data/planet-config.ts — add a new boolean or string field to PlanetDef (e.g. isTowerDefense: boolean or mode: 'standard' | 'tower_defense' | 'leaderboard'). All existing planets compile-error until the field is filled in (or made optional with ?).
  2. 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.