Campaign Mission Authoring Guide
A working handbook for adding single-player campaign missions to DOMI-NATION. Everything here is derived from the actual runtime code; the authoritative sources are:
unity/Assets/Scripts/Game/CampaignData.cs- MissionDef schema, JSON converters, chain/unlock logicunity/Assets/Scripts/Game/CampaignUI.Triggers.cs- the trigger runtime (events, actions, timers, waves)unity/Assets/Scripts/Game/CampaignUI.Binding.cs- named-entity binding and ref resolutionunity/Assets/Scripts/Game/CampaignUI.Dialog.cs- dialog presentation, choicesunity/Assets/Scripts/Game/CampaignProgress.cs- persistent distrust + completion (PlayerPrefs)unity/Assets/StreamingAssets/data/campaign/*.json- the missionsunity/Assets/StreamingAssets/data/maps/*.json- the authored maps (unity/Assets/Scripts/Map/MapDescriptor.cs)
1. Architecture overview
The campaign is fully data-driven. Drop a JSON file in unity/Assets/StreamingAssets/data/campaign/
and it appears on the campaign-select screen; no code changes are needed for a normal mission.
Data flow:
MissionDef.LoadAll()scansdata/campaign/*.json. Malformed files are skipped WITH a logged reason ([Campaign] Skipped '<file>': ...); a healthy load logs[Campaign] loaded N, skipped 0. Missions withact <= 0default to act 1.Campaign.Build()groups missions into chains byfaction(usa/russia/china; anything else, e.g.united_front, lands in the trailing finale group) and sorts each chain byact, then by ordinal missionid.Campaign.IsUnlocked()drives the select screen: the first mission of each chain is always unlocked; each later mission unlocks when the previous one is complete; the finale unlocks only when every act-4 mission across all factions is complete (Campaign.FinaleUnlocked; if no act-4 mission exists, ALL base-faction missions are required).- Starting a mission hands the
MissionDeftoGameController.MissionDef.ApplyMaploads the authored map (data/maps/<mapId>.json) or falls back to the inlinemapblock.SetupMissionbuilds the player base at start 0 and spawnsintro.playerUnitsthere;intro.enemyUnitsspawn at the enemy start and immediately push the player HQ. CampaignUIshows the briefing, thenBeginPlaystarts the act music (Campaign.MusicKeyForAct, stems underResources/audio/campaign/) and callsInitRunner, which armsscriptedTriggers, runs the content-binding pass, and firesmissionStarttriggers.TickRunnerpolls every active trigger each frame while Playing. A satisfied event + condition gate fires the trigger's actions in order (per-actiondelaysupported).endMission/completeMission/failMissionforce the outcome; a win plays thevictory[]cutscene and callsCampaignProgress.MarkComplete, which may queue an act-break montage (Campaign.ActBreakTransition, once-only viaCampaignProgress.ActBreakSeen).- If a mission has NO
scriptedTriggers, the legacy win applies: clear the map of enemy units. If it HAS triggers, the legacy auto-win is disabled and you MUST script a win path.
Persistence (CampaignProgress, PlayerPrefs-backed): campaign_distrust (0..100, ending gate at
DistrustThreshold = 60), campaign_completed (semicolon-joined mission ids),
campaign_actbreak_seen_<act>. CampaignProgress.Reset() wipes all three.
Campaign saves
CampaignSaves layers named save slots on top of that progression state. Each slot is a JSON file at
<persistentDataPath>/saves/campaign/<slot>.json (Newtonsoft, the project standard). Slots: slot1 /
slot2 / slot3 (manual, via the campaign-select screen's SAVE buttons), autosave (written after
every mission victory, from CampaignUI right after CampaignProgress.MarkComplete), and quicksave
(written by the in-mission pause menu's SAVE CAMPAIGN PROGRESS button). The file stores
schemaVersion, name, savedAtUtc, distrust, completed[], actBreaksSeen[], campaignFlags[],
and a display-only summary (missions complete / highest act / furthest mission). Loading a slot
restores the progression PlayerPrefs (clear-then-set) and returns to the campaign map.
What is NOT saved: mid-mission match state (unit positions, economy, timers, objective progress). A
loaded mission restarts from its briefing. That in-mission serialization is the separate #315 epic;
the schema reserves a missionState object and a schemaVersion gate for it, so #315 can add the blob
without a breaking format change. A file whose schemaVersion is newer than the running build is
reported as unusable (never mis-parsed), and a corrupt slot shows as CORRUPT rather than crashing the
menu.
2. Creating a new mission, step by step
- Pick id + filename. File name must be
<id>.json. Conventions: mainline<faction>_m<N>.json(russia_m4), side mission<faction>_a<act>_<slug>.json(usa_a2_black_box_recovery), finaleunited_finale.json. Chain order within an act is ordinal string order of the id, sousa_a2_...sorts beforeusa_m2inside act 2 (a<m). - Set
faction,enemy,act,missionType.factionmust beusa/russia/chinato join a chain (anything else goes to the finale group).enemymust be a roster indata/factions/(meridian,insurgent_proxy,proxy_coalition, ... exist). A missing faction degrades to a fallback roster with a logged warning (ResolveFactionOrFallback), it does not crash. Act 4 has a gate side effect: every act-4 mission you add becomes a requirement for unlocking the finale. An act-5 faction mission (likerussia_m5) does NOT gate the finale. - Author or pick a map, set
mapId(section 6). Keep the inlinemap {name,biome,w,h}block as the fallback. - Write the briefing - 3 beats: SITUATION / OBJECTIVE / COMPLICATION (see
usa_m5). The briefing panel truncates vertically; 5-7 paragraph briefings are a known problem (#1201). - Write typed objectives (section 3), dialog (section 4), intro rosters (unit ids must exist in the named faction JSONs), victory[] and defeat[] lines.
- Script the triggers (section 5). Every trigger-scripted mission needs BOTH a reachable win
(
endMission win/completeMission) and a reachable loss (failMission, or the board loss: the player's HQ dying still ends the mission). - Validate (section 7), then run the Unity gates.
3. Objectives
objectives accepts two shapes (ObjectiveListConverter); author the typed Schema-B object form:
{
"objectiveId": "obj_snatch_handler",
"type": "primary",
"targetRef": "handler_railcar",
"description": "Breach the railcar and secure the handler alive",
"failCondition": "Handler's railcar destroyed",
"completionTrigger": "t_m5_handler_snatched"
}
- Parsed keys:
objectiveId/id,description/displayText/text,type(defaultprimary),targetRef/target,optional,primary.type: "bonus"implies optional. failConditionandcompletionTriggerare NOT parsed - they are authoring documentation. Objectives change state ONLY viasetObjective/completeObjective/failObjectiveactions.- Bare-string objectives get auto ids
i0,i1, ... Triggers may reference an objective by its authored id, by index key (i1), or by 1-based alias (o2). primaryobjectives drive the "OBJECTIVE COMPLETE" announcer and the legacyprimaryDoneflag; bonus objectives get the "secondary complete" line.- HUD text rules:
descriptionis the tracker line - keep it one line, imperative, ALL-CAPS tag prefixes read well ([AIR] Destroy the ... battery, seeusa_m5). The briefing joins all objective texts on one row, so keep them short.showObjectiveUpdatetoasts truncate at 64 chars.
4. Dialogue
Each entry in dialog[] (MissionDialog):
| Field | Meaning |
|---|---|
id |
Handle used by showDialog/playDialog actions and the choice-trigger convention |
speaker |
Callsign shown on the lower-third box (e.g. "HAWKEYE", "SOKOLOVA") |
portrait |
Portrait key for the radio box |
faction |
Frame colour key: usa / russia / china / meridian / oracle |
text |
The subtitle line |
vo |
Optional VO clip key: resolves Resources/audio/campaign/<vo>.wav (missing clip = subtitle only) |
postPause |
Optional silence after the clip in seconds (0.2-3.0); omit for punctuation-aware defaults |
choices |
Optional 2-entry branch; blocks auto-advance until the player picks |
VO naming: put wavs at unity/Assets/Resources/audio/campaign/<key>.wav and name keys after the
dialog id (russia_m5_brief1 -> russia_m5_brief1.wav). Trigger playVO/playVoiceover uses the
same folder.
Choices: picking choice N fires the trigger whose id is exactly <dialogId>_choice<N>
(CampaignUI.Dialog.cs OnDialogChoice -> DebugFireTrigger). The choice trigger's event field is
never polled - the id string IS the wiring. Example from usa_a2_black_box_recovery:
dialog usa_a2_choice_prompt + triggers usa_a2_choice_prompt_choice0 / _choice1. The UI reads
each choice trigger's net addDistrust and tags the button with its trust stake (#1011).
Distrust: addDistrust amounts are clamped onto a persistent 0..100 meter; the Act-V ending
gate is >= 60. Keep per-choice deltas within +/-25; shipped content uses -12..+20 (usa_m5
purge = +15, extract = -12).
Delayed consequences (#1201 guidance): don't make every choice immediate meter math. Latch a
per-mission flag now (setFlag path_assault) and pay it off later with flag-gated triggers - the
usa_a2 pattern: choice0 sets path_assault + spawns a garrison wave labeled
waveId: "clearing_garrison"; a later waveDefeated trigger gated on {type:"flag", flag:"path_assault"} completes the branch objective and spawns alerted crash-site defenders.
Per-mission flags reset at InitRunner. For a payoff in a LATER mission (#1201 rec 3), set the flag with
scope: "campaign" ({type:"setFlag", flag:"usa_a2_went_loud", value:true, scope:"campaign"}) - it
persists via CampaignProgress and a {type:"flag"} gate in any later mission reads it (section 5). Use
addDistrust for a cumulative cross-mission meter; campaign flags are for discrete remembered choices.
Shipped arc: usa_a2 assault/stealth choice -> usa_a2_went_loud/usa_a2_went_quiet campaign flags ->
paid off in usa_m3 (loud = extra Meridian drone response; quiet = free drone-intel bonus). An unset flag
= neutral baseline, so out-of-order play / replay is safe.
Dialogue audio behavior (automatic, #1356/#2595). The mix and spoken scheduler handle it:
- Music ducks ~-10 dB and combat SFX ~-4 dB whenever a dialog
voclip is playing, then releases over ~1 s after it ends, so radio chatter stays intelligible over a firefight or the score. The duck multiplies onto the player's music/SFX sliders and the combat-intensity music arc - it never overwrites them, and it is campaign-only (skirmish has no dialog VO, so its mix is unchanged). - One spoken-narrative channel. Mission announcements, campaign dialogue, EVA alerts, and commander
lines serialize through one bounded priority queue. Paired
playVoiceoverandplayDialogactions with the same clip deduplicate automatically instead of restarting the recording. Unit barks yield while scheduled narrative is active. - VO-length-aware pacing. A line with a
voclip HOLDS until the clip finishes (plus a 0.65-0.9 s beat and its typewriter reveal), never cutting itself off; only a line with NOvouses the read-time timeout. Pacing implication: a long VO line (e.g. a 25 s monologue) holds the box for its full length before the next queued line shows - do NOT assume a fixed on-screen time when you chain a long VO line into the next beat. Achoices[]line still holds indefinitely until the player picks. - Priority without overlap. Critical EVA alerts outrank ordinary announcements and commander chatter, but never create a second simultaneous voice. Expired low-value alerts are dropped rather than spoken late. Mission-start announcements complete before the first briefing line.
- Pronunciation. Future generated recordings must apply
unity/tools/campaign_voice_pronunciations.json; subtitles retain the authored spelling. Runpython3 unity/tools/audit_campaign_voice.py --strictafter changing campaign dialogue or clips.
5. Triggers
Three authored schemas are accepted and normalized by ScriptedTriggerListConverter:
Schema A (canonical: id, event string, condition object, actions[]),
Schema B (triggerId, event object with inline qualifiers, conditions[] typed gates),
Schema C (event string + top-level targetRef/zone/timerId/timerSeconds/hpThreshold,
once:false = repeating, offsetSeconds folded onto every action's delay).
Author new content in canonical Schema A only. A malformed trigger is logged and skipped
individually; it never blanks the mission.
{ "id": "t_m5_handler_reached",
"event": "reachArea",
"condition": { "target": "zone_handler_car" },
"actions": [
{ "type": "showDialog", "ref": "russia_m5_handler_reached" },
{ "type": "setObjective", "id": "obj_infiltrate_yard", "state": "complete" } ] }
Triggers fire once then deactivate unless repeating: true (repeating timers re-base off the last
fire). Fire order inside a trigger is action order; delay (seconds) defers a single action.
Event vocabulary (NormalizeEvent + Evaluate)
| Event | Fires when | Condition keys |
|---|---|---|
missionStart |
Once at BeginPlay, before the first tick | - |
reachArea (aliases unitEntersZone, escortReachesWaypoint) |
A qualifying unit is within radius (default 5 cells) of the resolved point. tag naming a registered entity restricts to THAT unit; player_*/*_lead/*_squad or no tag = any player unit |
target (zone/point ref), gx/gy, radius, tag |
timerElapsed |
seconds since mission start, OR since the trigger named in after fired. With only a timerId (no seconds) it routes to the named-timer expired edge |
seconds, after, timerId |
timerExpired |
A named startTimer countdown crossed 0 (edge, once per arm) |
timerId |
unitDestroyed |
count form: live player units <= count. Named form: no live unit matches target/tag, guarded so it fires only after the unit was SEEN ALIVE (no tick-0 false positive) |
count or target/tag |
unitHealthBelow |
A watched unit drops to <= threshold HP fraction (default 0.4); edge-detected |
tag/target, threshold |
buildingCaptured |
A matching building's faction becomes the player's (edge per building instance) | target (strategicPoint id/type, building id, handle) |
buildingDestroyed |
No live matching building remains - only after it was seen alive (same guard as unitDestroyed) | target |
buildingDamaged |
A matching building drops to <= threshold HP fraction (default 0.5); edge |
target, threshold |
objectiveComplete |
Watched objective state == complete (empty id = the primary objective) | objectiveId/target |
detected |
Stealth detection level >= level (min 1). Needs stealth.enabled; escalation also spawns stealth.detectionAlarmsSpawn[level-1] |
level |
wave |
Timer semantics (a wave is a timer-gated spawn; escalating schedules = several wave triggers) | seconds, after |
waveDefeated (#1201) |
Every unit of the labeled spawn cohort is dead/removed. The wave must have SPAWNED first (never-spawned or empty waves stay dormant); a re-spawn under the same waveId reinforces the cohort - ALL members must die. Fires once |
event waveId -> condition.target |
missionVictory / missionDefeat |
Never board-polled. Fired explicitly by EndMission (#974) as end hooks: their showDialog texts are collected into the cutscene closer, playVO plays immediately, other actions run. offsetSeconds collapses to ordering |
|
dialogChoice |
Never polled at all - the trigger fires by the <dialogId>_choice<N> id convention (section 4). The event object is documentation only |
State-flag refs are a special target form: a target/tag/objectiveId ending _threshold,
_lost, or _recaptured is evaluated as runtime state, never geometry - distrust thresholds read
CampaignProgress.Distrust >= 60, convoy/escort *_threshold = escort dead, *_lost = any
objective failed, *_recaptured = any objective complete (CampaignUI.Binding.cs EvalStateFlag).
Action vocabulary (RunAction)
| Action | Fields | Semantics |
|---|---|---|
showDialog / playDialog |
ref |
Queue a dialog line by id (lower-third radio box) |
spawn / spawnWave |
units[] (or unitTemplate + count), id (anchor), gx/gy, faction, ally, waveId, delay |
Spawn units at the anchor (rows of 3). Default owner = the mission enemy; faction overrides; ally: "<roster>" fields THAT roster's prefabs OWNED BY THE PLAYER (controllable, "REINFORCEMENTS ARRIVED"). Hostile spawns auto-push the player HQ + fire the under-attack alarm. waveId labels the cohort for waveDefeated. A non-az_/zone_/wp_ id registers the group as a named entity handle |
killUnit / removeUnit |
id |
Scripted death: despawn by handle or unit-def id, with explosion FX (narrative deaths, e.g. Sokolova) |
setObjective |
id, state |
complete / fail / reveal / active (reveal+active announce "NEW OBJECTIVE", no complete VO) |
activateObjective / completeObjective / failObjective |
objectiveId |
Schema-B synonyms of setObjective |
showObjectiveUpdate |
text |
HUD toast (truncated at 64 chars) |
setFlag |
flag, value (default true), scope |
Latch a branch flag. Default scope (""/"mission") = per-mission (resets each mission). scope:"campaign" persists it via CampaignProgress across missions + restarts (#1201 rec 3) for a later-mission payoff |
playVO / playVoiceover |
clip/voId |
Play Resources/audio/campaign/<clip>.wav on the dedicated VO source |
playMusic |
clip |
Swap the music stem (no-op if unregistered) |
addDistrust |
amount |
Persistent campaign distrust delta (clamped 0..100) |
endMission |
result: win/victory else lose |
Force the outcome next frame; fires the end hooks, flushes queued dialog |
completeMission / failMission |
- | endMission win / lose synonyms. failMission reason is flavour-only |
startTimer |
id, duration (or seconds) |
Arm a named countdown for timerExpired/timerElapsed-by-id |
stopTimer |
id |
Cancel a countdown so its timerExpired never fires |
setPosture |
posture (garrison|assault), waveId (optional cohort), leash (optional) |
#1357b Flip ALREADY-SPAWNED units' posture at runtime (alarm escalation). waveId targets that labeled cohort; no waveId = every unit hostile to the player (mission-wide). assault sends them charging the player HQ (readable-cause alarm beat); garrison re-holds them at their current cell |
revealMap, lockdownMap, enableExfil, setUnitDisguise |
- | Logged no-ops (presentation verbs). Never make a gameplay-critical step depend on them |
Condition gates (ConditionGateMet, AND-ed with the event)
| Gate | Shape | Semantics |
|---|---|---|
| objectiveState | {"type":"objectiveState","objectiveId":"...","state":"active|complete|fail"} |
Watched objective must be in that state (active = not complete and not failed) |
| distrust | {"type":"distrust","comparison":"below"|"atLeast","value":N} |
below = Distrust < N (high trust); atLeast = Distrust >= N. Legacy distrustValue with op >=/< folds onto this; op:"eq" is a no-op |
| flag | {"type":"flag","flag":"<name>","value":true} |
Branch gate. As the singular condition with no event qualifier it fires PURELY on the flag; alongside a real event it is an AND gate. Resolution order (ResolveFlag, #1201 rec 3): mission-first, then the persistent CAMPAIGN flag of the same name - so one {type:"flag"} gate reads a within-mission latch OR an earlier mission's scope:"campaign" choice; a mission flag SHADOWS a campaign flag of the same name. Unset in both = false = baseline |
| detectionLevel | {"type":"detectionLevel","value":N} |
Coarse: treated as detection >= N regardless of authored op |
| timerValue | {"type":"timerValue","timerId":"..."} |
Coarse: only the timerId is carried; no numeric comparison is evaluated |
Named-point resolution (TryResolveCell, most specific wins)
explicit gx/gy -> map spawnPoints[].id (az_*) -> map zones[].id (zone_*/wp_*) ->
strategicPoints[].id -> strategicPoints[].type -> registered entity's live cell ->
playerHQ / enemyHQ. An unresolved ref degrades to a logged no-fire; the bind pass prints
[MissionRunner] <mission>: bound X refs, Y unresolved (list) at mission start - read that line.
6. Map authoring for missions
Maps live at unity/Assets/StreamingAssets/data/maps/<mapId>.json (MapDescriptor). Mission-relevant
parts:
spawnPoints[]{id, gx, gy, label, faction}. Two kinds by naming convention:- Playable starts: any id NOT prefixed
az_(MapLoader.IsPlayableSpawn). These are army start pads. az_*anchors: spawn zones for scriptedspawn/spawnWaveactions only; excluded from start resolution.- PITFALL - single playable spawn: a campaign mission always resolves 2 army starts
(
GameController:n = mission != null ? 2 : ...).MapLoader.ResolveSpawnsuses authored starts only whenplayable.Count >= n; otherwise it falls back toComputeStarts()for ALL armies - so a map with one playable spawn (e.g.usa_m5_empty_quiver, onlyp1_usa_start) has its authored player start IGNORED and both bases land on procedural ring positions. Author at least 2 non-az_spawnPoints (player first, enemy second).MapLoader.ValidateConnectivityA*-checks every playable pair and warns on disconnects.
- Playable starts: any id NOT prefixed
resourceNodes[]{type: "ore"|"derrick", gx, gy, rx, ry}. Ore blobs and capturable oil derricks (derricks snap to the nearest reachable cell, #607).strategicPoints[]{id, type, gx, gy, label, hp, failsOnDestroy}- what each type ACTUALLY spawns at runtime:type Runtime result garrisonNeutral civilian garrisonable building (2x2, 700 hp, cap 5) via PlaceGarrison. Placement rules: buildable land (not water/bridge/rock/road/ore), >= 11 cells from both bases, >= 9 from other garrisonsrepairRepair pad (snap-to-reachable) barrelsCluster of 4 explosive barrels (authored clusters REPLACE the procedural scatter) fuel_depotCluster of 6 barrels. Place on a bridge APPROACH, not the span (soft-blocks the crossing) tech/tech_objectiveCURRENTLY NOTHING SPAWNS - open issue #1283. hp/captureRadiusparse but are never read, sobuildingCaptured/buildingDestroyedtriggers targeting these ids can NEVER fire (russia_m4 and usa_m5 wins are unfireable today). The pending fix spawns a real enemy-owned, capturable/destroyable buildingEntityInstanceat the cell, honoring authoredhp. Until it merges, do not hang a win condition on atech_objectivepointA strategicPoint
idis also the campaign building handle: the bind pass registers whatever building stands on its cell, andBuildingMatchesresolvesbuilding*trigger targets by SP id, SP type, or raw building id.zones[]{id, x/y (or gx/gy), radius, label}- circular trigger areas. Naming:zone_*for reach/detect areas,wp_*for escort waypoints. Zones not referenced by triggers are fine (descriptive), but every zone a trigger references must exist.overlayTiles[]{gx, gy, tile}- per-cell tile overrides applied after the generator (water/bridge also lower elevation; rock raises it).terrainRowsis the full-authored alternative (withgenerator: "none");seed > 0makes generation deterministic.
7. Validation checklist before shipping a mission
Cheap checks first (a throwaway Python validator in your session scratchpad directory - NOT committed to the repo - is the established pattern):
- JSON parses (
python3 -c "import json; json.load(open(...))") for the mission AND the map. - ASCII only - the campaign files must stay plain ASCII (use
\u2014escapes if you must). - Vocabulary check - every
eventand actiontypeappears in the section-5 tables. Anything else arms but stays dormant (logged "unhandled event ... dormant"). - Ref resolution - every
showDialog refexists indialog[]; every objective id referenced by actions/gates exists inobjectives; everyaz_*/zone_*/strategicPoint ref exists in the map JSON; every choice dialog has its<dialogId>_choice0and_choice1triggers. - Rosters - every unit id in
intro.*and inspawn/spawnWaveactions exists in the roster faction's JSON (data/factions/<faction>.json); checkally/factionspawns against THAT roster, not the enemy's. - Timers - every
timerExpired/timerElapsed-by-id watcher has astartTimerthat can run before it;stopTimerids match. - Win AND lose both reachable - trace at least one action path to
endMission winand one tofailMission/endMission lose(a triggered mission disables the clear-the-map auto-win). - Wave sanity - every
waveDefeatedwaveId is spawned by some reachablespawnWavefirst. - Unity gates -
unity/compile-check.shfor any code change;unity/run-tests.shruns the campaign logic checks (Campaign_AllMissionsLoad,Campaign_TriggerLists_NonNull,Campaign_Binding_*, unlock/act/music checks inBuild.RunLogicChecks);unity/build-game.shis the merge gate. - Runtime smoke - start the mission and read the log:
[Campaign] loaded N, skipped 0,[MissionRunner] armed K trigger(s), and the bind summarybound X refs, 0 unresolved.
Starting economy (startingBase) - #1339
By default a campaign mission spawns the full legacy FOB (Construction Yard + power + refinery +
barracks + war factory) and the faction's default starting cash (5000). Author an OPTIONAL
startingBase object to take full control of a mission's economy:
"startingBase": { "mcv": false, "credits": 0, "buildings": [] }
- Omit
startingBaseentirely = legacy behavior, byte-identical to before: full FOB + default cash. This is the default for every existing mission - do not add the key unless you want to change the economy. - Present
startingBase= the mission OWNS its economy; all three sub-fields are honored:buildings- stamp exactly these at the player start. Each entry resolves as a building id first, then as a role key (power,refinery,barracks,war_factory,tech,construction_yard,superweapon, ...) against the PLAYER faction roster. An empty list = NO base at all (a pure infantry/stealth insertion). Keep lists short and ordered; entries stamp on a simple 3-wide grid at the start location.mcv-truespawns the roster'smcvunit at the start so the player unpacks their own base.credits- the starting cash.0means broke - an authoredstartingBasewithcredits:0starts the player with zero cash; it does NOT fall back to 5000. Omit the wholestartingBaseobject if you want the default cash.
- Defeat semantics (READ THIS for no-base missions). Engine defeat derives from Construction-Yard
loss. A
startingBasewith no CY has NO engine loss condition, so the mission MUST author its own reachable lose path and you must verify at least one loss trigger is reachable regardless of player unit count (see section 8).
8. Known limitations and open issues
- #1283 -
tech_objectivespawns nothing.building*triggers targeting those points can never fire (the seen-alive guard requires a live building first). russia_m4 / usa_m5 win paths are dead until the engine fix lands. Also flagged there: CI has NO campaign-binding logic check thatbuilding*targets resolve to livemap.buildingsafter world build - the unresolved-ref summary only proves the id exists in the map JSON. - dialogChoice id fragility. The choice wiring is a raw string convention
(
<dialogId>_choice<N>). Renaming a dialog id silently orphans its branch triggers (the runtime only logs "no follow-up trigger ... flavour-only"). - Coarse ops.
detectionLevelgates always evaluate>=;timerValuegates carry only the timerId with no comparison;distrustValue op:"eq"is a no-op. - Presentation-only verbs.
revealMap,lockdownMap,enableExfil,setUnitDisguiseare logged no-ops (#1201 recommends implementing or clearly renaming them). - No-base missions have no engine-driven defeat (#1339). Engine defeat derives from
Construction-Yard loss, so a mission that authors a
startingBasewithout aconstruction_yard(emptybuildings, or abuildingslist with no CY) can never lose by engine and MUST author its own reachable lose path (atimerElapsed/timerExpired,detectedlevel, last-unitunitDestroyed, orbuildingDestroyedtrigger whose actions end the mission in a loss). Such a mission's map must also NOT place an enemyconstruction_yardthe player can destroy - razing the only CY on the board while holding none forces a false DEFEAT (OnFactionEliminated). Future CI idea: aCampaign_NoBaseNeedsLosePathcheck that flags a no-CYstartingBasemission lacking a scripted loss trigger (or an enemy CY on a no-base map). - Briefing truncation. The briefing body uses vertical truncation; long briefings get cut. Keep to the 3-beat rule.
- Immediate trust tags. Choice buttons show the trust stake up front (#1011), which can turn
moral choices into meter optimization - prefer delayed/tactical consequences (#1201). Cross-mission
payoffs are now supported (#1201 rec 3): persist a choice with
setFlag scope:"campaign"and gate a later mission's triggers on it (section 5) instead of surfacing an immediate distrust delta.
9. Future expansion
- New factions / DLC chains. A mission's
factionfield plusCampaign.FactionOrderis the extension point: add the faction key toFactionOrder(and a roster JSON registered byGameData.Load) and its missions form a new chain automatically; unknown factions already fall back safely (finale group /ResolveFactionOrFallback). - Explicit ordering (#1201). Chains are ordered by
actthen ordinal id, which is fragile when several missions share an act (USA act 2/3). Proposal: addorder(orrequires) to MissionDef, keepactfor presentation/escalation, and use explicit ordering for unlocks. - Schema consolidation. New content should be Schema A; Schemas B/C exist only for legacy
missions. Long-term: migrate the authored files and add a
Campaign_SupportedEventVocabularylogic check so an off-vocabulary event failsrun-tests.shinstead of going dormant.
10. Enemy posture (garrison vs assault) - #1357
By default every campaign enemy spawns and immediately charges the player HQ
(SetupMission orders the intro raiders at PlayerHQWorld(); DoSpawn does the
same for hostile waves). That reads as nonsense when the force is site security
standing on top of the thing it is guarding. Posture lets an authored force
HOLD its ground for a readable reason instead.
Design rule: holds need a reason, attacks need a cause
- Garrison a force when it is defending a fixed thing (a launch complex, a compound, a bridgehead, an AA site). It anchors where it spawns and fights only what comes to it.
- Keep a force
assaultonly when there is a readable in-fiction CAUSE for it to move on the player - an alarm tripped, a countdown/QRF timer, a story beat, a counterattack the player provoked. If you cannot name the cause, garrison it.
Fields
enemyPosture(MissionDef, top-level string; default"assault"). Posture for the wholeintro.enemyUnitsforce.intro.enemyUnitsis a bare id list with no per-unit fields, so its posture is set once here."garrison"= the intro force holds the enemy base/site (each unit anchors at its own spawn cell).posture(perspawn/spawnWaveaction; default""= assault)."garrison"= the spawned hostile units hold their spawn cell. Ignored forally:spawns (player-owned reinforcements are always player-controlled).leash(perspawn/spawnWaveaction; cells; default0= unitsight + 2). Garrison engagement/return radius, measured from the ANCHOR (the spawn cell), NOT from the unit. A garrison unit engages only targets withinleashof its anchor, never chases beyond it, and walks back to the anchor (re-facing its spawn heading) when nothing is in-leash.
Semantics
- Anchor = the unit's spawn cell (intro: its
SetupMissioncell; wave: itsDoSpawncell). The leash is anchor-relative, so a garrison unit can push a few cells to meet an intruder but always returns. - A garrison unit at low HP retreats to its anchor (not to a base it may not own).
- Posture is spawn-time state on the unit; nothing else re-orders idle campaign
enemies (
aiEnabled=false), so a garrison unit with no in-leash target holds. - Default is
assaulteverywhere, so a mission that sets neitherenemyPosturenor an actionposturebehaves exactly as before.
Example (usa_m5 "Empty Quiver")
"enemyPosture": "garrison", // intro perimeter guards HOLD the launch complex
// perimeter reaction stiffens the site defence (holds); it does not charge the FOB:
{ "type": "spawnWave", "waveId": "perimeter_react", "faction": "meridian",
"posture": "garrison", "spawnZoneId": "az_complex_perimeter", "unitTemplates": [ ... ] }
// the QRF surge + extract counterattack STAY assault - they have a readable cause
// (the fueling-clock timer / the player choosing to extract the CHIMERA drive).
The mission stays winnable/losable: the player is forced to attack by the launch countdown, and no objective depends on the enemy coming to the player.
Guard-objective posture (posture: "guard") - #1357b
A guard force is a garrison (holds its spawn cell within leash) that ALSO watches
one building and CONVERGES on it when it takes damage.
guardRef- the building/SP id the force guards (matched byBuildingMatches: a strategicPoint id/type at the building's cell, an entity handle, or a def id).responseRadius(cells; defaultleash + 4). Only guards whose anchor is within this radius of the guarded building respond.- Converge = while the building's
lastDamagedTimeis within a 6 s window, responders drop the anchor leash and engage anything withinresponseRadiusof the building, then fall back to their anchor when the window lapses. The damage signal is the existing singleDamageBuildingsink (EntityInstance.lastDamagedTime) - one field read per guard per tick, no polling of all buildings, no new hook. - Use it for a force defending a fragile objective (a reactor you must take INTACT): a careless AOE hit on the objective wakes its guards; a clean capture (no HP loss) does not.
Patrol posture (posture: "patrol") - #1357b
A patrol force walks a fixed 2-4 waypoint loop when idle and engages within leash
of the patrol PATH (nearest point on the polyline), NOT of a drifting anchor.
patrol- an array of 2-4 waypoints, each an explicit{ "gx": N, "gy": N }cell OR a named{ "point": "<ref>" }(resolved byTryResolveCell). Fewer than 2 valid waypoints degrades safely to a plain garrison hold at the spawn cell.- Anti-kite: because the leash reference is the fixed path (not the unit's position at
acquisition), a player can pull a patroller at most
leashoff its route before it drops the target and resumes - there is no ratchet where re-acquisition drags it toward the bait. - Author waypoints within ~`2 * leash` cells of each other so the leash disks overlap into a continuous engagement corridor along the route.
Runtime posture flip (setPosture action) - #1357b
setPosture is the readable-cause ALARM mechanism: an authored beat (a gate destroyed, a
detection threshold, a timer) flips ALREADY-SPAWNED units at runtime so a holding garrison
SORTIES at the player.
posture-"assault"(default) sends each target charging the player HQ (and fires the under-attack announcer);"garrison"re-holds each target at its current cell.waveId- flips only that labeled cohort (thespawnWavewaveId, tracked bywaveUnits). Omit it to flip EVERY unit currently hostile to the player (mission-wide).leash- optional new leash (cells) for agarrisonflip.- Design rule holds: never flip TO assault without a cause the player can read. Pair it with
the event that supplies the cause (
buildingDestroyed <gate>,detected,timerElapsed).
Pilot: russia_m4 "Tsar's Gambit" (garrison + alarm)
The reactor complex demonstrates the loud/quiet fork:
enemyPosture: "garrison"- the ALERT intro force HOLDS its corner instead of charging.- A
missionStarttrigger spawns 2 reactor guards (posture: "guard",guardRef: "reactor_core",leash: 4at cell 53,54 - the fortified SE screen) and 2 ORACLE garrison guards (leash: 5at 70,30). Leash 4 from 53,54 leaves the reactor's OPEN west capture ring (49, 50-53; nearest cell 49,53 is ~4.1 cells > leash 4) uncovered, so a Spetsnaz engineer coming from the tunnel exit (38,50) along gy=50 reaches the ring untouched - the QUIET path is tense (a guard sits ~4 cells away) but viable. - The existing gate alarms (
buildingDestroyed perimeter_gate_north/south) gainsetPosture{assault}(mission-wide): breaching a gate sends the whole complex - reactor guards, ORACLE guards, and the intro reserve - charging the player. The LOUD path trades the quiet advantage for a fight with a readable cause. - Both win (
buildingCaptured reactor_core) and lose (buildingDestroyed reactor_core) chains are untouched; capture is HP-neutral so guarding never blocks the intact seizure.