planet-progression.ts
Per-planet 10-level XP reward track. Each planet has 10 cumulative XP thresholds and 10 typed rewards. XP feeds in from challenge bounties and base run completion XP.
Contract
- Reward levels are 1-indexed (1–10).
xpThresholds[i]= cumulative XP to reach leveli+2.- Players start at level 1 (0 XP). Thresholds gate levels 2–10.
- Harder planets need more XP but their challenges award proportionally more.
Types
| Symbol | Shape |
|---|---|
PlanetRewardType | union: custom_event | ship_common | weapon_1 | ship_uncommon | alt_boss | ship_rare | planet_master | ship_epic | weapon_2 | global_buff |
TrackRewardRarity | union: common | uncommon | rare | epic | legendary |
PlanetTrackReward | { level, type, name, description, lockedLabel, emoji, placeholderId } |
PlanetTrackDef | { planetId, xpThresholds: number[], baseRunXp: number, rewards: PlanetTrackReward[] } |
Reward slot map (fixed per planet)
| Level | Type | Rarity | Locked label | Emoji |
|---|---|---|---|---|
| 1 | custom_event | common | Unlock: | 🏎️ |
| 2 | ship_common | common | Unlock: New Ship | 🚀 |
| 3 | weapon_1 | uncommon | Unlock: New Weapon | ⚔️ |
| 4 | ship_uncommon | uncommon | Unlock: New Ship | 🚀 |
| 5 | alt_boss | rare | Unlock: Secret Boss | 👹 |
| 6 | ship_rare | rare | Unlock: New Ship | 🚀 |
| 7 | planet_master | epic | Unlock: Planet Master | 🌟 |
| 8 | ship_epic | epic | Unlock: New Ship | 🚀 |
| 9 | weapon_2 | legendary | Unlock: New Weapon | ⚔️ |
| 10 | global_buff | legendary | Unlock: Global Buff | 💎 |
Rarity → level mapping
TRACK_REWARD_RARITY[] indexed 0–9:
- Levels 1–2 →
common - Levels 3–4 →
uncommon - Levels 5–6 →
rare - Levels 7–8 →
epic - Levels 9–10 →
legendary
Color tables
TRACK_RARITY_COLORS (border/accent):
| Rarity | Hex |
|---|---|
| common | #ffffff |
| uncommon | #50ff78 |
| rare | #44aaff |
| epic | #cc66ff |
| legendary | #ffd228 |
TRACK_RARITY_BG (2-tone for collectibles + card headers):
| Rarity | dark | light |
|---|---|---|
| common | #2a2a2a | #ffffff |
| uncommon | #0a5c2a | #d4ffd8 |
| rare | #0a2a6e | #d8eaff |
| epic | #3a0a5c | #f0d8f8 |
| legendary | #a03000 | #ffe8b0 |
Base XP thresholds (Landing Site = 1.0× baseline)
BASE_THRESHOLDS = [100, 250, 500, 800, 1200, 1700, 2300, 3000, 4000, 5500]
Cumulative — index i gates level i+2. Level 10 cap = xpThresholds[9].
Per-planet XP multiplier (PLANET_XP_MULT)
| Planet ID | Name | Multiplier | Base run XP |
|---|---|---|---|
| 12 | Landing Site | 1.0× | 10 |
| 21 | Sunrise City | 1.5× | 15 |
| 3 | Voidstar | 2.0× | 20 |
| 30 | Solaris | 1.0× (placeholder) | 10 |
| 31 | Speedway | 1.0× (placeholder) | 10 |
| 32 | Eden | 1.0× (placeholder) | 10 |
| 33 | Old Earth | 1.0× (placeholder) | 10 |
| 34 | Network Station | 1.0× (placeholder) | 10 |
| 35 | Delphi | 1.0× (placeholder) | 10 |
| 36 | Desolation | 1.0× (placeholder) | 10 |
| 37 | Obelisk | 1.0× (placeholder) | 10 |
xpThresholds is built per planet as BASE_THRESHOLDS.map(t => Math.round(t * mult)).
Per-planet reward naming
Each planet supplies six naming slots used by buildRewards():
| Planet | event | boss | buff |
|---|---|---|---|
| 12 Landing Site | Racing Event | Secret Boss | Global Defense Boost |
| 21 Sunrise City | City Event | Secret Boss | Global Speed Boost |
| 3 Voidstar | Void Event | Secret Boss | Global Damage Boost |
| 30 Solaris | Solar Event | Secret Boss | Global Fire Rate Boost |
| 31 Speedway | Speed Event | Secret Boss | Global Crit Boost |
| 32 Eden | Eden-5 Event | Secret Boss | Global HP Regen |
| 33 Old Earth | Earth Event | Secret Boss | Global Shield Boost |
| 34 Network Station | Network Event | Secret Boss | Global XP Boost |
| 35 Delphi | Oracle Event | Secret Boss | Global Luck Boost |
| 36 Desolation | Wasteland Event | Secret Boss | Global Armor Boost |
| 37 Obelisk | Obelisk Event | Secret Boss | Global Magnet Boost |
All ship, w1, w2 slots are currently 'TBD' / 'TBD Weapon'. placeholderId is '' for every reward — to be assigned per planet later.
Build pipeline
buildRewards(planetId)→ 10PlanetTrackRewardobjects, slot-fixed, names interpolated from per-planetnames[].buildTrackDef(planetId)→{ planetId, xpThresholds, baseRunXp, rewards }.PLANET_TRACKS: Record<number, PlanetTrackDef>— exported map, populated by callingbuildTrackDeffor all 11 planet IDs at module load.
Exported helpers
| Function | Returns | Notes |
|---|---|---|
getPlanetTrack(planetId) | PlanetTrackDef | Throws Error("No planet track for planet ${planetId}") if missing. |
getLevelForXp(planetId, xp) | number (1–10) | Iterates thresholds, bumps level for each crossed, clamps to 10. |
getXpProgress(planetId, xp) | { level, currentXp, nextThreshold, pct, totalXp } | nextThreshold is the span (range) for the next level, not absolute. pct clamped 0–1. At level 10 returns { level: 10, currentXp: 0, nextThreshold: 0, pct: 1, totalXp: xp }. |
getOverallPct(planetId, xp) | number (0–1) | xp / xpThresholds[9] clamped to 1. For the compact hub bar. |
Level computation rules
getLevelForXp semantics:
- Start at
level = 1. - For each threshold
xpThresholds[i], ifxp >= threshold→level = i + 2, else break. - Final clamp:
Math.min(level, 10).
getXpProgress semantics:
- If
level >= 10→ bar full, no further progress. - Else
thresholdIdx = level - 1;prevThreshold = thresholdIdx > 0 ? xpThresholds[thresholdIdx - 1] : 0. currentXp = xp - prevThreshold;range = nextThreshold - prevThreshold;pct = currentXp / range(clamped, 0 if range ≤ 0).- Returned
nextThresholdfield is the range, not the absolute threshold value.
Dependencies
PlanetIdfrom./planet-config.
EXTRACT-CANDIDATE
BASE_THRESHOLDS,PLANET_XP_MULT,BASE_RUN_XP→ move into a data table (e.g.planet-progression-data.tsor merged intoplanet-config) so balance pass can edit numbers without touching logic.- Per-planet
namesmap insidebuildRewards→ extract to aPLANET_REWARD_NAMES: Record<PlanetId, ...>constant alongside the other planet data tables; the function should be pure templating. - Reward slot template (the 10-row array literal) → extract to
REWARD_SLOT_TEMPLATEconstant;buildRewardsbecomes a.mapinterpolatingn.*into the template, removing 11× near-duplicate object literals. placeholderIdfield is''everywhere — either wire up real ship/weapon IDs or delete the field until placeholders are resolved.'Secret Boss'is hardcoded across every planet — collapse thebossslot innamesto a constant until alt-boss identities diverge.getXpProgressreturningnextThresholdas a range (not absolute) is a footgun named badly — rename tonextLevelXpRangeor expose bothnextThresholdAbsandnextThresholdRange.