Mission Objectives

Every mission posting on the board carries an objective label — a one-word, positively framed verb that tells the player what the contract is about. The four labels collapse what used to be seven cosmetic synonyms (Survey/Investigate → Explore, Recover → Find, Patrol → Battle, Escort → Protect) into a small set with distinct engine tuning. Each label maps to a MissionObjectiveTuning record that nudges two real levers — enemy count and starting weapon-box count — so the four labels feel like different vibes, not just different copy.

The extraction timer is an orthogonal axis driven by the posting’s difficulty, not its objective. Difficulty owns “how long do I survive”; objective owns “what does the field feel like”.

Naming clash, read carefully. “Mission objective” means two different things in code. The player-facing label (MissionObjectiveLabel in data/mission-postings.ts) is one of Explore | Find | Protect | Battle. The engine-side MissionObjective interface (data/run-config.ts) is a { type, count } pair where type is a lowercase string like 'survive_timer' or 'explore', and count is a beacon/kill quota. The two taxonomies live side by side; the lowercase one is the active win-condition the engine evaluates, the title-case one is the board-card flavor + tuning hook.

The four labels

Source of truth: src/starship-survivors/data/mission-postings.ts (lines 60–96).

LabelenemyCountMultweaponBoxCountFeel
Explore1.0×0Baseline. Nothing extra placed, nothing taken away.
Find0.85×2Calmer, more loot. Fewer enemies, two extra weapon boxes on the ground.
Protect1.15×1Mild pressure plus a weapon to defend with.
Battle1.4×0Pure pressure, no extra loot. The fight tier.

enemyCountMult here is a multiplier on top of the planet × rarity × challenge stack that assembleRunService already applies — see services/assembleRunService.ts lines 79–108. The spread is deliberately conservative (1.0× to 1.4×) so it doesn’t dominate the existing difficulty curve.

weaponBoxCount is consumed by engine/bridge.ts (the spawn loop reads runDef.node.weaponBoxCount and places that many crates at world start). A caller-supplied params.node.weaponBoxCount always wins over the per-objective default.

Extraction timer per difficulty

Source of truth: EXTRACTION_TIMER_BY_DIFFICULTY in data/mission-postings.ts (lines 143–149). The 4-minute Standard tier is the spec default; surrounding tiers fan out by ±60 seconds per step so the curve is monotonic with difficulty.

DifficultyTimer (seconds)Timer (minutes)
Routine1803:00
Standard2404:00
Hazardous3005:00
Critical3606:00
Black Flag4207:00

This value is written onto each posting at module load (extractionTimerSeconds: EXTRACTION_TIMER_BY_DIFFICULTY[difficulty]) so postings stay in sync with the table.

The TypeScript record is exhaustive over MissionDifficulty — adding a new difficulty tier without a matching entry fails the build, which is how we keep the curve honest.

Posting → run assembly

The translation from “card the player tapped” to “engine config the run loop reads” runs through a couple of files:

  1. Mission board pick. pickTwoPostings(seed) / pickTwoPostingsForPlanet(planetId, seed) in data/mission-postings.ts return two distinct MissionPosting records to the MissionBoardScreen.
  2. Player accepts. MissionBoardScreen resolves the planet name via PLANETS[posting.planetId].name and hands the chosen posting to the run-start handler.
  3. Engine config built. services/assembleRunService.ts assembleRunDef({ shipId, node, planetIndex, isChallenge }) produces a RunDefinition. The node overrides are merged onto DEFAULT_RUN.node (line 56–59), so the posting’s extractionTimerSeconds lands on runDef.node.timerSeconds, and the per-objective weaponBoxCount lands on runDef.node.weaponBoxCount.
  4. Engine boots. engine/bridge.ts reads runDef.node.timerSeconds and writes it into game.missionTimer at run start; engine/rendering/mission-timer-hud.ts surfaces it as the “SURVIVE M:SS until extraction” banner. The same bridge reads runDef.node.weaponBoxCount and places that many crates in the world. Enemy count flows through runDef.context.worldKnobs.enemyCountMult, which the spawn director consumes wave by wave.

The posting’s objectiveLabel itself is not stored on RunDefinition — only its effect on the run (timer + crate count + enemy mult) survives the translation. That’s intentional: the engine doesn’t care what the contract said, it cares about the levers the contract set.

Engine-side MissionObjective (separate concept)

data/run-config.ts defines a different MissionObjective interface:

export interface MissionObjective {
  /** Objective type: 'explore' | 'survive_timer' | 'kill_target' | 'collect' | 'escort' | 'defend' */
  type: string;
  /** Number of beacons/items/kills required (0 = N/A for survive_timer) */
  count: number;
}

This lives on RunDefinition.node.objective and is the win condition the engine evaluates each frame. The default run uses { type: 'survive_timer', count: 0 } — survive until the extraction timer hits zero. Postings don’t currently override this; every accepted contract runs on the survive-timer win condition regardless of its title-case objectiveLabel. The lowercase type taxonomy is older and broader than the four-label set, kept around for future objective variants (kill-target, escort, defend) that would change the win check.

  • mission-board — UX layer that picks pairs and renders the card
  • mission-postings — full catalogue of the 11 postings
  • planets — supplies the per-planet enemyCountMult that stacks with objective tuning
  • run-assembly — full pipeline from metagame selection to RunDefinition