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 (
MissionObjectiveLabelindata/mission-postings.ts) is one ofExplore | Find | Protect | Battle. The engine-sideMissionObjectiveinterface (data/run-config.ts) is a{ type, count }pair wheretypeis a lowercase string like'survive_timer'or'explore', andcountis 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).
| Label | enemyCountMult | weaponBoxCount | Feel |
|---|---|---|---|
| Explore | 1.0× | 0 | Baseline. Nothing extra placed, nothing taken away. |
| Find | 0.85× | 2 | Calmer, more loot. Fewer enemies, two extra weapon boxes on the ground. |
| Protect | 1.15× | 1 | Mild pressure plus a weapon to defend with. |
| Battle | 1.4× | 0 | Pure 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.
| Difficulty | Timer (seconds) | Timer (minutes) |
|---|---|---|
| Routine | 180 | 3:00 |
| Standard | 240 | 4:00 |
| Hazardous | 300 | 5:00 |
| Critical | 360 | 6:00 |
| Black Flag | 420 | 7: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:
- Mission board pick.
pickTwoPostings(seed)/pickTwoPostingsForPlanet(planetId, seed)indata/mission-postings.tsreturn two distinctMissionPostingrecords to theMissionBoardScreen. - Player accepts.
MissionBoardScreenresolves the planet name viaPLANETS[posting.planetId].nameand hands the chosen posting to the run-start handler. - Engine config built.
services/assembleRunService.tsassembleRunDef({ shipId, node, planetIndex, isChallenge })produces aRunDefinition. Thenodeoverrides are merged ontoDEFAULT_RUN.node(line 56–59), so the posting’sextractionTimerSecondslands onrunDef.node.timerSeconds, and the per-objectiveweaponBoxCountlands onrunDef.node.weaponBoxCount. - Engine boots.
engine/bridge.tsreadsrunDef.node.timerSecondsand writes it intogame.missionTimerat run start;engine/rendering/mission-timer-hud.tssurfaces it as the “SURVIVE M:SS until extraction” banner. The same bridge readsrunDef.node.weaponBoxCountand places that many crates in the world. Enemy count flows throughrunDef.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.
Related
- mission-board — UX layer that picks pairs and renders the card
- mission-postings — full catalogue of the 11 postings
- planets — supplies the per-planet
enemyCountMultthat stacks with objective tuning - run-assembly — full pipeline from metagame selection to
RunDefinition