How to design a new mission posting
This guide walks an AI designer through adding a new mission posting end-to-end. Mission postings populate the Mission Board screen — each posting is an in-universe contract card describing a planet, hiring faction, difficulty tier, objective, and reward profile. The player sees two postings at a time and taps one to launch a run on that planet.
Before you start
A mission posting is data-only. It is a record in MISSION_POSTINGS that the UI renders as a card. Posting data does not drive mission flow directly — it routes the player into the run pipeline by planet id. To change mission flow itself, edit the run pipeline (run-config / assembleRunService), not the posting.
A posting wraps four player-facing concepts:
| Concept | Field |
|---|---|
| Where | planetId (key in PLANETS) |
| Who hired you | faction |
| What you’ll do | objectiveLabel + objectiveBlurb |
| What you get + how hard | difficulty + payoutLine + extractionTimerSeconds |
Postings are positively framed: copy describes what the operative will DO, never loss conditions. Loss conditions are never surfaced on the card.
Step 1: Identity
Decide the following before writing code:
| Slot | Notes |
|---|---|
id | Stable unique string. Existing ids use the pattern posting-N. |
planetId | Must be a member of PlanetId and present in PLANET_ORDER. |
faction | Must be one of the ten MissionFaction union members. |
difficulty | One of Routine, Standard, Hazardous, Critical, Black Flag. |
objectiveLabel | One of Explore, Find, Protect, Battle. |
objectiveBlurb | One sentence, in-universe, positively framed. No loss copy. |
payoutLine | Card-bottom payout summary, starts with PAYOUT:. |
postedBy | In-universe author tag shown at the bottom of the card. |
Step 2: Write the data file
Append a new entry to the MISSION_POSTINGS array in src/starship-survivors/data/mission-postings.ts. The order of postings in the array loosely tracks PLANET_ORDER, so insert the new posting next to others on the same planet.
MissionPosting fields:
| Field | Type | Purpose |
|---|---|---|
id | string | Stable unique identifier for the posting. |
planetId | PlanetId | Key into PLANETS; resolved to display name at render time. |
faction | MissionFaction | Hiring faction label on the card. |
objectiveLabel | MissionObjectiveLabel | One of 4 tuning-bound objective types. |
objectiveBlurb | string | One-line in-universe task description. Positively framed. |
difficulty | MissionDifficulty | Difficulty pill. |
payoutLine | string | Card-bottom payout summary. |
postedBy | string | In-universe author tag. |
extractionTimerSeconds | number | Survive-time until extraction, in seconds. |
Resolve the timer using EXTRACTION_TIMER_BY_DIFFICULTY[difficulty] rather than hardcoding a number. The compiler enforces the map is exhaustive over MissionDifficulty.
Step 3: Difficulty + scaling
difficulty sets the extraction timer (survive-time until extraction) via the lookup table below. The engine reads extractionTimerSeconds into RunDefinition.node.timerSeconds, which engine/bridge.ts writes into game.missionTimer; engine/rendering/mission-timer-hud.ts then surfaces it as the SURVIVE M:SS until extraction banner.
| Difficulty | extractionTimerSeconds | Minutes | Tier purpose |
|---|---|---|---|
| Routine | 180 | 3 | Onboarding tier, snappy |
| Standard | 240 | 4 | Spec default |
| Hazardous | 300 | 5 | Mid-tier endurance |
| Critical | 360 | 6 | Late-game |
| Black Flag | 420 | 7 | Endurance tier |
Difficulty also reads through to RunDefinition.node.heat (1–10) when the run is assembled — heat controls enemy scaling — but the posting does not set heat directly. Postings inform the run; the run pipeline owns the scaling knobs (worldKnobs.enemyHpMult, enemyCountMult, enemyDamageMult, rewardMult, rarityScale).
objectiveLabel adds a secondary tuning layer that assembleRunService.assembleRunDef applies when missionObjective is passed:
objectiveLabel | enemyCountMult | weaponBoxCount | Vibe |
|---|---|---|---|
| Explore | 1.0 | 0 | Baseline, nothing extra placed |
| Find | 0.85 | 2 | Calmer, more loot |
| Protect | 1.15 | 1 | Mild pressure + a weapon to defend with |
| Battle | 1.4 | 0 | Pure pressure, no extra loot |
enemyCountMult from the objective is layered multiplicatively on top of planet, rarity, and challenge multipliers. The conservative spread (1.0×–1.4×) keeps the objective from dominating the existing difficulty stack. weaponBoxCount is consumed by engine/bridge.ts’s spawn loop via runDef.node.weaponBoxCount; a caller-supplied params.node.weaponBoxCount overrides this default.
Step 4: Rewards
The posting carries reward intent in two fields:
| Field | What it does |
|---|---|
payoutLine | A PAYOUT: ... string rendered at the bottom of the card. Flavor + headline reward only — not a contract with the economy system. |
difficulty | Drives worldKnobs.rewardMult in the assembled run (higher difficulty pays more). |
Concrete rewards are computed at mission end by the engine and surfaced through MissionResult (see rewards). Reward summary copy in payoutLine should match the run pipeline’s actual output for that difficulty; the posting itself does not change reward math.
Style notes for payoutLine (from existing postings):
| Tier | Example |
|---|---|
| Routine | PAYOUT: 1× Artifact Cache + Salvage Rights |
| Standard | PAYOUT: SDA Field Stipend + 1× Gear Requisition Token |
| Hazardous | PAYOUT: 2× Artifact Cache + Network Access Key |
| Critical | PAYOUT: Commission Purse + 1× Prototype Drive Core |
| Black Flag | PAYOUT: CLASSIFIED — Authorised personnel only |
Step 5: Faction + flavor
Pick a faction from the existing MissionFaction roster:
| Faction |
|---|
| Cygnus Freight Co. |
| Outer Rim Salvage |
| Sol Defense Authority |
| Independent Operators |
| Eden Reclamation Corps |
| Network Sysadmins |
| Delphi Mining Guild |
| Wasteland Brokers |
| Speedway Commission |
| Voidstar Research Div. |
If you need a new faction, add it to the MissionFaction union in mission-postings.ts. The faction is currently a cosmetic label — it does not gate enemy spawn pools directly.
Enemy spawn pools are owned by the planet, not the faction. Each PlanetDef declares an enemySet (e.g. bugs, city, bugs_mortar, bugs_shooter, bugs_charger, bugs_sniper, bugs_field, bugs_racer, bugs_mixed, bugs_heavy), and the run inherits enemies from that set. See enemies for the full set roster. If you want a particular pool of enemies, choose a planetId whose enemySet matches.
Step 6: Register + display
Two postings are shown on the Mission Board at a time, selected by the helpers in mission-postings.ts:
| Helper | Behavior |
|---|---|
pickTwoPostings(seed) | Returns two distinct postings sampled deterministically from all MISSION_POSTINGS. Seed defaults to Date.now() so the board varies on each page visit. |
pickTwoPostingsForPlanet(planetId, seed) | Biases the pair toward a specific planet. Card A is a posting matching planetId; card B is a deterministically-picked posting from a different planet. Falls back to pickTwoPostings(seed) if zero postings match. |
Both helpers are deterministic for a given seed, so refresh/back-button on the board route does not reroll the offers.
Multiple postings per planet are allowed — pickTwoPostingsForPlanet picks one of the matches deterministically. There is no auto-registration step beyond appending to MISSION_POSTINGS; the helpers read the array on every call.
Step 7: Validate
Verify the following before considering the posting shipped:
| Check | Expected |
|---|---|
| Card renders on Mission Board | Planet name, hiring faction, difficulty pill, positive objectiveLabel, payoutLine, postedBy all visible. |
| Loss copy | Absent from the card and blurb. |
| ACCEPT routes correctly | Tapping the card launches a run on posting.planetId. |
| HUD timer | SURVIVE M:SS until extraction shows, M:SS matches extractionTimerSeconds. |
| Reward summary | Matches the design intent for the posting’s difficulty tier. |
| Determinism | Same seed always yields the same pair from pickTwoPostings / pickTwoPostingsForPlanet. |
| Build | tsc clean, no console errors, vitest passes. |
Custom-element structure rule
Mission postings are data-only. Do not add behavior fields to MissionPosting — keep new mechanics in the run pipeline (run-config.ts, assembleRunService.ts, engine/bridge.ts). The posting layer exists to surface jobs to the player; what happens after ACCEPT is owned by the run pipeline. Adding new tuning levers belongs on RunDefinition.node or RunDefinition.context.worldKnobs, not on MissionPosting.