Build a mission from scratch
This page walks through a whole mission, from an empty folder to a finished run. It uses the tools from Finding things in game and the patterns from Recipes.
The example mission has four beats:
- The player lands. A goal points at a gate.
- At the gate, enemies spawn. The goal says “clear the area”.
- When they are dead, the gate opens and a line plays.
- Past the gate, a trigger ends the mission.
All keys below are placeholders. You will find the real ones in step 2.
Step 1: pick the activity and plan
- Pick an activity whose map has what you need: an area to fight in, a door, triggers.
- Find its script path and mission module. See Your first script, steps 3 and 4.
- Write the beats down as a list, like the one above. Each beat is one step later.
For each beat, note:
| question | example answer |
|---|---|
| what starts it | the previous beat ended |
| what the player sees | a goal, a line, enemies |
| what ends it | a trigger, a clear, an interaction, a timer |
Step 2: survey the map
Launch the activity and walk the route with the tools open.
- Turn on the Current Status overlay. Note the region number of each area you pass.
- On States, match each region to a state key.
- For each area, open these pages with Current state only where it exists:
| you need | page | note down |
|---|---|---|
| the arrival state | States | the state key where the player lands |
| the goal sensor and goals | Directives | sensor key, directive keys |
| the voice lines | Dialogue | sensor key, cue numbers |
| enemies | Squads | squad keys, and their objective slots |
| doors | Devices | device keys; test with Set channel |
| triggers | Objects, Triggers | trigger keys; test with Fire trigger |
| map markers | Objects | type-47 navpoint keys |
- Test each piece with its page button before you use it in code.
Keep the notes in a content file. That is the next step.
Step 3: write the content file
Put every key in one place. Check each one when the file loads.
-- scripts/my_mission/content.lua
local lib = require("lib.mission_lib")
return function(mission)
local Slot, Squad = mission.Slot, mission.Squad
return {
arrival = lib.one(mission.states.STATE_AAAA_0001_0000_BBBB, "arrival state"),
gate_area = lib.one(mission.states.STATE_AAAA_0002_0000_CCCC, "gate state"),
goals = lib.one(Slot.M_DIRECTIVE_SENSOR_DDDD, "goal sensor"),
goal_reach = lib.one(mission.Directive.REACH_THE_GATE, "reach goal"),
goal_clear = lib.one(mission.Directive.CLEAR_THE_AREA, "clear goal"),
gate_marker = lib.one(Slot.AP_GATE, "gate marker"),
voice = lib.one(Slot.M_DIALOG_SENSOR_DDDD, "dialogue sensor"),
line_open = lib.one(mission.DialogueCue.M_DIALOG_SENSOR_DDDD.CUE_3, "gate line"),
gate = lib.one(Slot.D_GATE, "gate device"),
at_gate = lib.one(Slot.PT_GATE, "gate trigger"),
at_exit = lib.one(Slot.PT_EXIT, "exit trigger"),
objective = lib.one(Slot.OBJ_GATE_FIGHT, "fight objective"),
squads = {
{squad = lib.one(Squad.SQ_GATE_A, "squad a"), slot = lib.one(Slot.SQ_GATE_A, "slot a")},
{squad = lib.one(Squad.SQ_GATE_B, "squad b"), slot = lib.one(Slot.SQ_GATE_B, "slot b")},
},
}
end
If a key is wrong, the script fails at open with a clear message, before anything reaches the game.
Step 4: write the program
Use lib.flow. Each beat becomes one or two steps.
-- scripts/my_mission/my_mission.lua
local missions = require("missions")
local mission = require(missions.MY_SCENARIO)
local lib = require("lib.mission_lib")
local flow = require("lib.flow")
local content = require("my_mission.content")(mission)
-- Show one goal, with an optional marker.
local function show(context, directive, marker)
context:slot(content.goals):set_directive{
directive = directive,
navpoint = marker ~= nil and context:slot(marker) or nil,
}
end
-- Arm a trigger and remember it, so its first report can disarm it.
local function arm(context, id)
context:set_variable("armed/" .. id, true)
context:slot(id):fire_trigger()
end
local function squads_of_fight()
local list = {}
for _, unit in ipairs(content.squads) do list[#list + 1] = unit.squad end
return list
end
local FIGHT = squads_of_fight()
local function trigger_fact(id, slot)
return {id = id, observe = function(context, _, event)
return lib.is_slot(context, event, slot)
end}
end
local graph = flow.new{
key = "mm",
facts = {
{id = "arrived", observe = function(_, _, event)
return event.entered == true
and event.held_region_index == content.arrival.region_index
end},
{id = "gate_area", observe = function(_, _, event)
return event.region_index == content.gate_area.region_index
end},
trigger_fact("at_gate", content.at_gate),
trigger_fact("at_exit", content.at_exit),
},
steps = {
-- Beat 1: the player lands and gets a goal.
{id = "arrive", await = flow.fact("arrived")},
{id = "reach", after = {"arrive"}, run = function(context)
show(context, content.goal_reach, content.gate_marker)
end},
-- The gate trigger can only be armed once its area is loaded.
{id = "gate_loaded", await = flow.fact("gate_area")},
{id = "arm_gate", after = {"gate_loaded"}, run = function(context)
arm(context, content.at_gate)
end},
-- Beat 2: at the gate, the fight starts.
{id = "at_gate", after = {"reach", "arm_gate"}, await = flow.fact("at_gate")},
{id = "fight", after = {"at_gate"}, run = function(context)
show(context, content.goal_clear)
for _, unit in ipairs(content.squads) do
context:slot(unit.slot):assign_combat_objective{
objective = context:slot(content.objective),
}
context:squad(unit.squad):place{}
end
end, await = function(context)
return context:cohort{squads = FIGHT}.cleared
end},
-- Beat 3: the gate opens and a line plays.
{id = "open", after = {"fight"}, run = function(context)
context:slot(content.gate):transition{
transition = context.sdk.device_transitions.open,
}
context:slot(content.voice):play_dialogue_cue{cue = content.line_open}
arm(context, content.at_exit)
end, await = function(context)
return context:slot(content.gate):applied{
channel = context.sdk.device_channels.position,
}
end},
-- Beat 4: the exit trigger ends the mission.
{id = "exit", after = {"open"}, await = flow.fact("at_exit")},
{id = "done", after = {"exit"}, run = function(context)
context:slot(content.goals):clear_directives()
context:complete_mission{}
end},
},
}
local function handle(context, state, event)
graph:handle(context, state, event)
end
return {
initial_state = content.arrival,
on_start = function(context, state) graph:advance(context, state) end,
on_load = function(context, state) graph:advance(context, state) end,
on_event_client_state_changed = function(context, state, event)
if event.entered == true then handle(context, state, event) end
end,
on_event_region_changed = handle,
on_event_squad_state = handle,
on_event_device_state = handle,
on_event_player_trigger = function(context, state, event)
local slot = event.slot
if slot ~= nil and state:variable("armed/" .. slot.id) == true then
context:clear_variable("armed/" .. slot.id)
slot:disarm_trigger()
end
handle(context, state, event)
end,
}
How it fits together:
initial_stateputs the player in the arrival state.on_startandon_loadboth calladvance, so a reload picks up where the mission was.- Each event goes to
graph:handle. Facts latch, so a trigger that fired early is not lost. - A step whose
awaitis a function is checked again on every handled event. The squad and device events are wired in so the clear and the gate are noticed.
Step 5: test it
- Save both files.
- Launch the activity from orbit.
- Open the Script page. Check that the program is loaded and there is no error.
- Open Mission state. Each step shows as a variable
flow.mm.step.<id>. The value is 1 while a step waits and 2 when it is done. Facts show asflow.mm.fact.<id>. - Play through. Watch which step is stuck.
- Fix, save, press Reload script.
When a step never finishes, the matching fact never turned true. Check:
| stuck at | check |
|---|---|
arrive | the arrival state key; compare held_region_index with the Current Status overlay |
at_gate | the trigger was armed; the trigger key is right; you walked into its volume |
fight | the squads spawned; they share the objective’s registry; they were seen at full strength |
open | the device key is type 23; the device moved |
Step 6: make it robust
- Reloads. Everything important is in variables, so a reload is safe. Do not keep request key
handles in Lua locals. Store
key.value. - Early events. Facts latch. A trigger crossed before its step still counts.
- Skipped fights. A player can run past a fight. If that should end the step, use
flow.any(cleared, flow.fact("next_trigger"))as theawait. - Refused requests. Add
on_event_effect_resultfor requests that must work, such as a state change. - Wipes. In a private mission, add a checkpoint step and
restart_checkpoint. See “Wipe and restart at a checkpoint” in Recipes. - Public activities.
fireteam_stateand checkpoints do not work there. Checkcontext.activity_role. - Seeded objects. Objects you spawn later belong in
initial_state.omit.
A shortcut for story missions
A linear mission of goals, triggers and encounters fits lib.campaign. It builds the flows, arms
triggers per region, shows goals and plays lines from one content table. The Red War drafts under
scripts/mission_* use it. See The shared script libraries.
Checklist
- The file is
scripts/<name>/<name>.luaand the Script page finds it. - Every key goes through
lib.oneorlib.list. initial_stateis set.on_startandon_loadboth advance the mission.- Triggers are armed after their area loads, and disarmed on their first report.
- Objectives are assigned before squads are placed.
- Every fact the mission needs is stored in a variable.
- The mission ends with
clear_directivesandcomplete_mission. - A full run shows no error on the Script page.