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:

ConceptField
WhereplanetId (key in PLANETS)
Who hired youfaction
What you’ll doobjectiveLabel + objectiveBlurb
What you get + how harddifficulty + 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:

SlotNotes
idStable unique string. Existing ids use the pattern posting-N.
planetIdMust be a member of PlanetId and present in PLANET_ORDER.
factionMust be one of the ten MissionFaction union members.
difficultyOne of Routine, Standard, Hazardous, Critical, Black Flag.
objectiveLabelOne of Explore, Find, Protect, Battle.
objectiveBlurbOne sentence, in-universe, positively framed. No loss copy.
payoutLineCard-bottom payout summary, starts with PAYOUT:.
postedByIn-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:

FieldTypePurpose
idstringStable unique identifier for the posting.
planetIdPlanetIdKey into PLANETS; resolved to display name at render time.
factionMissionFactionHiring faction label on the card.
objectiveLabelMissionObjectiveLabelOne of 4 tuning-bound objective types.
objectiveBlurbstringOne-line in-universe task description. Positively framed.
difficultyMissionDifficultyDifficulty pill.
payoutLinestringCard-bottom payout summary.
postedBystringIn-universe author tag.
extractionTimerSecondsnumberSurvive-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.

DifficultyextractionTimerSecondsMinutesTier purpose
Routine1803Onboarding tier, snappy
Standard2404Spec default
Hazardous3005Mid-tier endurance
Critical3606Late-game
Black Flag4207Endurance 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:

objectiveLabelenemyCountMultweaponBoxCountVibe
Explore1.00Baseline, nothing extra placed
Find0.852Calmer, more loot
Protect1.151Mild pressure + a weapon to defend with
Battle1.40Pure 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:

FieldWhat it does
payoutLineA PAYOUT: ... string rendered at the bottom of the card. Flavor + headline reward only — not a contract with the economy system.
difficultyDrives 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):

TierExample
RoutinePAYOUT: 1× Artifact Cache + Salvage Rights
StandardPAYOUT: SDA Field Stipend + 1× Gear Requisition Token
HazardousPAYOUT: 2× Artifact Cache + Network Access Key
CriticalPAYOUT: Commission Purse + 1× Prototype Drive Core
Black FlagPAYOUT: 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:

HelperBehavior
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:

CheckExpected
Card renders on Mission BoardPlanet name, hiring faction, difficulty pill, positive objectiveLabel, payoutLine, postedBy all visible.
Loss copyAbsent from the card and blurb.
ACCEPT routes correctlyTapping the card launches a run on posting.planetId.
HUD timerSURVIVE M:SS until extraction shows, M:SS matches extractionTimerSeconds.
Reward summaryMatches the design intent for the posting’s difficulty tier.
DeterminismSame seed always yields the same pair from pickTwoPostings / pickTwoPostingsForPlanet.
Buildtsc 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.