Recipes
Short answers to how do I spawn, open, show, play, wait and finish.
Short answers to “how do I …”. Each recipe is a working pattern. The examples assume this header:
local missions = require("missions")
local mission = require(missions.MY_SCENARIO)
local lib = require("lib.mission_lib")
local Slot, Squad, Scene, Directive = mission.Slot, mission.Squad, mission.Scene, mission.Directive
local Input, Output = mission.SceneInput, mission.SceneOutput
Replace MY_SCENARIO and every Slot.X with real keys from your mission module.
A block made only of on_... = function ... end, lines shows entries of the program table. Put them inside the table your script returns. A block of plain statements runs inside a callback, where context, state and event exist.
Field note. A tip marked field note comes from a comment in an existing script under scripts/. It describes client behavior seen in game. It is not checked against the Sunrise source.
Contents
- Basics: constants, variables, timers, phases, random numbers, lists
- Where the player is: arrival, regions, states, triggers, occupancy
- Enemies: squads, counts, objectives, clears, damage
- The world: devices, objects, interactables, filters, darkness, map generators
- Presentation: goals, goal counters, dialogue, music, public events, idles, scenes, cinematics
- Actors: paths, abilities, atoms, sequences, commands
- Flow: request results, step graphs, several areas, checkpoints, completion
- Players: peers and party life
Basics
Keep constants safe
A missing key is nil and fails quietly later. Check keys when the file loads.
local DOOR = lib.one(Slot.D_FRONT_DOOR, "front door")
local GUARDS = lib.list(Squad.SQ_GUARD_A, Squad.SQ_GUARD_B)
The top-level code runs on every open, so these checks cost nothing later.
Remember something
context:set_variable("door_open", true)
if state:variable("door_open") == true then
-- the door was opened earlier
end
context:clear_variable("door_open")
- Only booleans, integers, numbers and short strings can be stored.
- Store a table as several variables, or as a short string you build yourself.
- Use a prefix per feature:
"door.open","wave.count".lib.scopedoes this for you.
Run something once
local function once(context, state, key)
if state:variable(key) then return false end
context:set_variable(key, true)
return true
end
return {
on_event_region_changed = function(context, state, event)
if event.region_index == 16 and once(context, state, "intro.done") then
-- first visit only
end
end,
}
The mark is stored, so a reload does not run the work again.
Wait some time
on_start = function(context, state)
context:start_timer("intro", 5000) -- 5 seconds
end,
on_event_timer_elapsed = function(context, state, event)
if event.timer_name == "intro" then
-- 5 seconds have passed
end
end,
Repeat every N seconds
A timer fires once. Start it again in its own callback.
on_event_timer_elapsed = function(context, state, event)
if event.timer_name == "wave" then
spawn_wave(context, state)
context:start_timer("wave", 45000)
end
end,
Stop it with context:cancel_timer("wave").
Track progress with a phase
local PHASE_INTRO, PHASE_FIGHT, PHASE_DONE = 0, 1, 2
return {
on_start = function(context, state)
context:set_phase(PHASE_FIGHT) -- raises phase_entered after the commit
end,
on_event_phase_entered = function(context, state, event)
if event.phase == PHASE_DONE then
-- the last stage has started
end
end,
}
state.phase reads the current phase. A variable works too. Use the phase when you want one number that names the stage.
Random numbers
math.random is removed. Keep a seed in a variable and step it.
local function next_seed(seed)
return (seed * 6364136223846793005 + 1442695040888963407) & 0x7FFFFFFFFFFFFFFF
end
local function roll(context, state, sides)
local seed = next_seed(state:variable("rng") or 0x2545F4914F6CDD1D)
context:set_variable("rng", seed)
return 1 + ((seed >> 17) % sides)
end
To vary the start, mix in a value from an event, such as event.sequence (a decimal string). scripts/tangled_shore/port.lua shows a full example with a shuffle.
Walk lists without pairs
local WAVES = {
{id = "a", squads = {Squad.SQ_A1, Squad.SQ_A2}},
{id = "b", squads = {Squad.SQ_B1}},
}
for _, wave in ipairs(WAVES) do
for _, squad in ipairs(wave.squads) do
-- use wave.id and squad
end
end
For lookups, build a map once at the top of the file, from a list:
local WAVE_BY_ID = {}
for _, wave in ipairs(WAVES) do WAVE_BY_ID[wave.id] = wave end
Compare strings without patterns
local function starts_with(text, prefix)
return string.sub(text, 1, #prefix) == prefix
end
Split a big script
The main file is limited to 128 KiB. Put helpers next to it and load them with the folder name:
scripts/my_mission/my_mission.lua
scripts/my_mission/content.lua -- require("my_mission.content")
A helper module can take the mission module as an argument:
-- scripts/my_mission/content.lua
return function(mission)
return {door = mission.Slot.D_FRONT_DOOR}
end
-- scripts/my_mission/my_mission.lua
local content = require("my_mission.content")(mission)
Where the player is
Choose where the player spawns
Set initial_state in the program. The host selects it before on_start runs.
return {
initial_state = mission.states.STATE_80B48062_0002_0001_80B4805D,
on_start = function(context, state)
-- the state is selected and published
end,
}
Give a table instead when you need more than the region:
key
meaning
region_index
the state to open on; required
spawn_set_hash
the authored spawn set the arrival and every host move use
omit
slots whose objects stay out of every seed; up to 32
Read the spawn set hash off the Closest spawn line of the Current Status overlay, standing where the player should arrive. See Finding things in game.
initial_state = {
region_index = mission.states.STATE_X.region_index,
spawn_set_hash = 0x8029E4B4,
omit = {Slot.O_PEDESTAL, Slot.O_PIKE_1},
},
Spawn an omitted object later with activate_objects or set_object_active.
Hold the spawn for an opening cutscene
A held spawn keeps the player without a body, so nothing is placed while an opening cutscene plays.
on_start = function(context, state)
context:hold_spawn{active = true}
context:slot(Slot.PF_OPENING_CINEMATIC):set_cinematic_active{active = true}
end,
on_event_cinematic_terminated = function(context, state, event)
context:slot(Slot.PF_OPENING_CINEMATIC):set_cinematic_active{active = false}
context:hold_spawn{active = false}
end,
activeis required. There is no default.- The hold rides the roster, so it applies on the next roster publication.
- Always release it. A hold that is never released leaves the player with no body.
- Handle
on_event_cinematic_skip_requestedthe same way, or a skip leaves the hold on.
React when the player first arrives
client_state_changed with entered == true is the host’s arrival answer.
on_event_client_state_changed = function(context, state, event)
if event.entered == true and event.held_region_index == START_REGION then
-- the player has the start area
end
end,
Field note: the client sends many state reports while it loads. React to entered, not to every report. A pending leg report names a region the client has not loaded yet.
React when the player enters an area
on_event_region_changed = function(context, state, event)
if event.region_index == mission.states.STATE_CAVE.region_index then
-- the player now holds the cave state
end
end,
region_changed fires only when the client holds the new region. It does not fire for a pending move.
Know when the player can move again after spawning
on_event_client_state_changed = function(context, state, event)
if event.teleport_state == context.sdk.client_teleport_reset then
context:set_variable("spawned", true)
end
end,
Field note (raid_gluttony_0.lua): after the spawn, a report with no region, spawn or teleport field means the player has control.
Move to another authored state
local key = context:select_state(mission.states.STATE_BOSS_ROOM)
context:set_variable("move_request", key.value)
- If the client does not hold that state’s region, the host arms a teleport.
- The result arrives as an
effect_result.refusedorexpiredmeans the move did not happen. - Select the state before you activate its objects and scenes. Scene requests wait until the new state is published.
With options:
context:select_state(mission.states.STATE_BOSS_ROOM, {
Slot.O_REWARD_CHEST, -- kept out of the seed
retire_placed_props = true, -- end the old state's map props before the move
})
Know when the player walks into a trigger
Type-31 player triggers report only after the host arms them.
local CAVE_REGION = mission.states.STATE_CAVE.region_index
local function arm(context, id)
context:set_variable("armed/" .. id, true)
context:slot(id):fire_trigger()
end
return {
on_event_region_changed = function(context, state, event)
if event.region_index == CAVE_REGION then
arm(context, Slot.PT_CAVE_ENTRANCE)
end
end,
on_event_player_trigger = function(context, state, event)
local slot = event.slot
if slot == nil then return end
if state:variable("armed/" .. slot.id) == true then
context:clear_variable("armed/" .. slot.id)
slot:disarm_trigger()
end
if lib.is_slot(context, event, Slot.PT_CAVE_ENTRANCE) then
-- the player crossed the cave entrance
end
end,
}
- Arm a trigger when its area is loaded. Arming also registers its object on the client.
- Field note: once armed again, a trigger reports on every update while the player stays inside. Disarm it on the first report.
- Field note: arming a trigger again reports a player who is already inside.
- The event also names the type-60 volume:
volume_registry_key,volume_slot_index.
Count who is inside a volume
A type-30 occupancy monitor reports trigger_entered and trigger_exited.
Set the condition once, for example when the area loads:
context:slot(Slot.PM_TOWER_TOP):set_occupancy_condition{value = 1}
Then react to its edges:
on_event_trigger_entered = function(context, state, event)
if lib.is_slot(context, event, Slot.PM_TOWER_TOP) then
-- event.member_count, event.all_inside
end
end,
- Without
filter, the client measures its default player set. - With
filter = context:slot(Slot.OF_SOMETHING), the client uses that filter. - The first report of a volume is recorded silently. The event comes on the next change.
Enemies
Spawn a squad
context:squad(Squad.SQ_GUARDS):place{}
- The squad spawns with its authored counts and spawn rule.
modepicks how the placement combines with what is there:
context:squad(Squad.SQ_GUARDS):place{mode = context.sdk.squad_modes.replace}
mode
wire value
use in the existing scripts
reinforce
0
the default; standing squads
replace
2
repeated waves, public event squads and resets in lib/freeroam.lua
reserve
3
a scene’s cast, placed just before the scene starts, in mission_scot.lua and mission_abs.lua
Spawn a different number
local squad = context:squad(Squad.SQ_GUARDS)
local counts = squad:counts() -- starts at the authored defaults
counts:set(1, 4) -- member 1: four of them
squad:place{counts = counts}
squad.member_count and squad.default_counts show what the squad has.
Remove a squad
Field note (adventure_ginger.lua): placing all-zero counts makes the client remove the members.
local squad = context:squad(Squad.SQ_GUARDS)
local counts = squad:counts()
for index = 1, counts.count do counts:set(index, 0) end
squad:place{counts = counts}
Field note (lib/freeroam.lua): a squad that still has a live member ignores a new placement. To fill it again, place all-zero counts first, then place it again later.
Use another spawn rule
context:squad(Squad.SQ_GUARDS):place{
spawn_rule = context:slot(Slot.SQ_GUARDS_SPAWNRULE),
}
The rule must be a type-66 slot.
Give a squad an objective
context:slot(Slot.SQ_GUARDS):assign_combat_objective{
objective = context:slot(Slot.OBJ_COURTYARD),
task_group = mission.TaskGroup.OBJ_COURTYARD.GROUP_0,
}
context:squad(Squad.SQ_GUARDS):place{}
- Call it on the squad’s type-1 slot, not the squad handle.
- The objective must be in the same registry as the squad, or the call errors.
- Field note: assign the objective before the placement. A squad whose body changes after its members exist reports alive 0 for about a second, and a cohort reads that as cleared.
Move a squad between task groups
local combat = require("lib.combat")
local GROUPS = lib.list(
mission.TaskGroup.OBJ_ARENA.GROUP_0,
mission.TaskGroup.OBJ_ARENA.GROUP_1
)
return {
on_event_squad_state = function(context, state, event)
if not lib.is_slot(context, event, Slot.SQ_ARENA) or event.alive_count <= 0 then
return
end
local objective = context:slot(Slot.OBJ_ARENA)
local current, assigned = event:task_group{objective = objective}
if not assigned then return end
local best, known = combat.lowest_cost(event, GROUPS, current)
if known and not combat.same_group(best, current) then
event.slot:assign_combat_objective{objective = objective, task_group = best}
end
end,
}
Wait until an encounter is cleared
local ARENA = {Squad.SQ_ARENA_A, Squad.SQ_ARENA_B}
local function arena_cleared(context)
return context:cohort{squads = ARENA}.cleared
end
clearedis true only after every squad was seen at full strength and is now all dead.- A placement still in the queue makes the squad unknown, so
clearedstays false. - Check it in
on_event_squad_state, or in alib.flowcondition.
React to each kill
on_event_entity_died = function(context, state, event)
if lib.is_slot(context, event, Slot.SQ_BOSS) and event.alive_count == 0 then
-- the boss squad is dead
end
end,
squad_state carries the same counts and also fires on the first report.
React when a squad is first hurt
squad_provoked fires on the first accepted damage to a squad. It has no slot field. Match the numbers:
on_event_squad_provoked = function(context, state, event)
local guard = context:slot(Slot.SQ_GUARD)
if event.registry_key == guard.registry_key and event.slot_index == guard.slot_index then
-- the guards were attacked
end
end,
Read an enemy’s health
Type-2 combatant damage pools and type-20 damage monitors raise damage_state.
on_event_damage_state = function(context, state, event)
if lib.is_slot(context, event, Slot.SQ_BOSS_BOSS) then
context:set_variable("boss.health", event.health)
end
end,
To watch an object instead, bind a damage monitor to it:
context:slot(Slot.DAMAGE_MONITOR):watch_damage{target = context:slot(Slot.O_GENERATOR)}
The world
Open a door or start a lift
context:slot(Slot.D_FRONT_DOOR):transition{
transition = context.sdk.device_transitions.open,
}
transition
does
open / close
position to 1 / 0
power_on / power_off
power to 1 / 0
lock / unlock
lock to 1 / 0
snap = truejumps to the end instead of moving.- The slot must be type 23. Device names often start with
D_.
Set a device to an in-between value
context:slot(Slot.D_BRIDGE):set_channel{
channel = context.sdk.device_channels.position,
value = context.sdk.unit(0.5),
}
Wait until the door has opened
local function door_open(context)
return context:slot(Slot.D_FRONT_DOOR):applied{
channel = context.sdk.device_channels.position,
}
end
appliedis false while your latest request is queued.- It turns true once the client has reported a value for that latest request.
- In
on_event_device_state,event:applied_request{channel = c}gives the exact request key. - The event also has
position,powerandlock.
Spawn or remove a placed object
context:slot(Slot.O_REWARD_CHEST):set_object_active{active = true}
Many at once:
context:activate_objects{
slots = {Slot.O_PIKE_1, Slot.O_PIKE_2, Slot.O_PIKE_3},
active = true,
}
activate_objectsgroups objects that share an owner into one request each.set_object_active{with = {...}}sends up to 63 more objects with the first one.- Field note: a seeded object spawns when its group registers. To spawn it later, keep it out of the seed with
omit, then activate it.
Let the player use an object
context:slot(Slot.O_PEDESTAL):set_interactable_object{used = true}
on_event_object_interacted = function(context, state, event)
if lib.is_slot(context, event, Slot.O_PEDESTAL) then
-- the player used it
end
end,
object_interactedfires once per object generation.track_owner = truealso reports who owns the object:owner_known,has_owner,owner_keyinobject_state.- Field note: without this request, the client never reports a use. A row not yet used shows a generic prompt, so the existing scripts send
used = true.
Know when an object is destroyed
on_event_object_state = function(context, state, event)
if lib.is_slot(context, event, Slot.O_SHIELD_GENERATOR) and event.present == false then
-- it is gone
end
end,
Run a Ghost scan
context:slot(Slot.GHOST_LINK):set_ghost_link{active = true}
on_event_ghost_link_state = function(context, state, event)
if not lib.is_slot(context, event, Slot.GHOST_LINK) then return end
if event.active and event.progress > 0 then
context:set_variable("scan.started", true)
elseif not event.active and state:variable("scan.started") then
-- the scan finished
end
end,
slot:ghost_link() reads the last reported level at any time, or nil before one exists.
Filter which objects a sensor counts
context:slot(Slot.OF_ARENA):set_object_filter{
players = true,
inside = context:slot(Slot.TV_ARENA),
}
key
adds
players
players only
target
one type-4 object
inside
objects inside a type-60 volume
inside_any
objects inside any of up to 5 volumes
Turn on a darkness zone
context:slot(Slot.HARD_WIPE_GLOBALS):set_darkness_zone{enabled = true}
-- later
context:slot(Slot.HARD_WIPE_GLOBALS):set_darkness_zone{enabled = false}
wipe_seconds (0 to 3) starts the wipe countdown. It needs enabled = true.
Run a map generator
context:slot(Slot.MAP_GENERATOR_SENSOR):generate_map{seed = 12345, enabled = true}
- The slot must be type 37.
lib.campaignhascampaign.run_seed(context), which gives a new seed on each attempt.- The other arguments are raw inputs whose meaning is not known yet. See Lua API reference.
Presentation
Show a goal on the HUD
context:slot(Slot.M_DIRECTIVE_SENSOR_80B9AF4B):set_directive{
directive = Directive.RENDEZVOUS_WITH_HAWTHORNE,
navpoint = context:slot(Slot.AP_ELEVATOR_TOWN),
}
- The directive must belong to that sensor. Its
sensorequals that sensor’smission.Slotvalue. navpoint(type 47) places the map marker.waypoint(type 60) is a volume where the marker hides.audience(type 70) is the engagement sensor for the mission banner.stateis 0, 1 or 2.
Field notes (lib/campaign.lua):
- A goal with no navpoint shows its text and no marker.
- Sending the same goal again moves the marker without a popup.
- A goal sent before its marker’s object is registered shows no marker. Send it again once the area that holds the object is loaded.
Show a counter on the goal
context:slot(Slot.M_DIRECTIVE_SENSOR_X):set_directive{
directive = Directive.X,
progress = {kills, 20},
}
- The directive entry must have
counter = truein the mission module. progressholds 1 to 4 int32 values, for example the current count and the maximum.- Send the goal again with new values each time the count changes.
Hide the goal
context:slot(Slot.M_DIRECTIVE_SENSOR_80B9AF4B):clear_directives()
Play a voice line
local cues = mission.DialogueCue.M_DIALOG_SENSOR_80B9AF4B
context:slot(Slot.M_DIALOG_SENSOR_80B9AF4B):play_dialogue_cue{cue = cues.CUE_14}
With a filter volume:
context:slot(Slot.M_DIALOG_SENSOR_80B9AF4B):play_dialogue_cue{
cue = cues.CUE_38,
filter = context:slot(Slot.SLOT_0017_80BDA140),
}
mission.DialogueCueVariants.<sensor>[n]holds the text of cuen. Search it to find a line.- Field note: a filtered line waits in the client until the player enters the filter volume.
- Field note: a filtered line plays reliably only when the player is already inside. Send it when a trigger inside that volume reports.
Change the music
context:slot(Slot.MUSIC_SENSOR):set_music_section{section = 3}
section is 0 to 127. enabled = false turns that section off.
Run a public event
context:slot(Slot.M_PUBLIC_EVENT_SENSOR_80FD9392):set_public_event_state{
area = context:slot(Slot.M_PUBLIC_EVENT_SENSOR_80FD9392),
leave_seconds = 30.0,
}
areais the object whose zone bounds the event.leave_secondsis how long a player may be outside before the client reports it.playerdefaults to this link’s player.lib.freeroamruns whole public event sites: it resets the objective, places the squads, sends this request, and restarts the event after a cooldown. See The shared script libraries.
Play NPC idle animations
context:slot(Slot.VENDOR_ZAVALA_IDLE):play_performance{
state = mission.PerformanceState.VENDOR_ZAVALA_IDLE.STATE_08BA6CD2,
}
- Leave out
statewhen the NPC has only one state. - Field note (
lib/mission_lib.lua): the sensor’s object must be active first.
Start an authored scene
Start the scene, then fire the inputs it waits on:
local portal = context:scene(Scene.SCENE_IKORA_OPENS_PORTAL)
portal:activate{}
portal:send_event{key = Input.SCENE_IKORA_OPENS_PORTAL.T_START_SCENE}
request
effect
activate{}
start the scene
activate{spawn = true}
also spawn its authored cast
stop{}
stop it
send_event{key = Input.X.NAME}
fire one of the scene’s inputs
- Look up the scene in
mission.SceneInputto see what it waits on. A scene with no entry there plays on its own. - Send an input after
activate{}, in the same callback or later. Requests leave in order. on_event_scene_finishedfires when the scene reaches its end.- An input named
T_FOREVERexists to be waited on forever. Do not send it. - A
KEY_<hash>name means the trigger’s name is unknown. The key is still exact.
Wait for a scene output
on_event_scene_output = function(context, state, event)
if lib.is_slot(context, event, Slot.SC_ZAVALA)
and event.key == Output.SC_ZAVALA.T_END_SCENE_START_HOP_ON then
-- the scene reported it is done with its part
end
end,
event.keyis the output;event.activation_tokennames the run that fired it.- Each output fires once per run of the scene. Starting the scene again starts a new run.
- The first report of a scene is recorded silently, like other first reports.
- An output arrives once and is not replayed. Keep a variable if a later step needs to know it came.
Drive a scene from a campaign step
{id = "portal", scene = Scene.SCENE_IKORA_OPENS_PORTAL,
inputs = lib.list(Input.SCENE_IKORA_OPENS_PORTAL.T_START_SCENE),
ends = {scene = Slot.SCENE_IKORA_OPENS_PORTAL}},
{id = "hop_on", scene = Scene.SC_ZAVALA,
inputs = lib.list(Input.SC_ZAVALA.ZAVALA_START),
ends = {output = {slot = Slot.SC_ZAVALA, key = Output.SC_ZAVALA.T_END_SCENE_START_HOP_ON}}},
inputsare sent right after the step starts its scene.- A step that ends on a scene hangs if the scene waits on an input nobody sends.
- See The shared script libraries for every step field.
Play a cinematic
context:slot(Slot.PF_CINEMATIC_BOOKEND_CINEMATIC):set_cinematic_active{active = true}
on_event_cinematic_terminated = function(context, state, event)
-- the cinematic ended, or its start was refused
end,
on_event_cinematic_skip_requested = function(context, state, event)
-- a player asked to skip
end,
- A skip request is its own event. Handle both callbacks if you react to the end.
- Field note (
raid_gluttony_0.lua): sendactive = falsebefore a state change, or the rebuilt component plays the cutscene again.
Play an authored sequence
context:slot(Slot.SEQ_WAVE_ANNOUNCE_START):play_sequence()
The slot must be type 5.
Reset or advance objectives
context:slot(Slot.OBJ_COURTYARD):reset_objectives()
context:slot(Slot.TASK_SENSOR):advance_task()
on_event_objective_progress = function(context, state, event)
-- event.objective, event.task, event.task_count, event.previous_task_count
end,
Actors
Actor requests go to a type-2 slot: one named member of a squad, such as SQ_HAWTHORNE_HAWTHORNE.
Walk an authored path
context:slot(Slot.SQ_ESCORT_ESCORT):play_actor_path{
path = context:slot(Slot.PATH_TO_GATE),
spawn = false,
}
The path must be a type-58 slot of the same object, with an authored destination.
Use an ability
local abilities = mission.ActorAbility.SQ_MINE_CAPTAIN_YARR
context:slot(Slot.SQ_MINE_CAPTAIN_YARR):play_actor_action{
ability = abilities.GROUP_1F992208.KEY_00479128,
target = context:slot(Slot.SOME_POINT),
}
Run a small program
local kinds = context.sdk.atom_kinds
context:slot(Slot.SKIFF_ACTOR):run_atoms{spawn = true, atoms = {
{kind = kinds.ability, ability = SKIFF_ENTER, target = context:slot(Slot.SKIFF_IN)},
{kind = kinds.sleep, seconds = 6},
{kind = kinds.ability, ability = SKIFF_EXIT, target = context:slot(Slot.SKIFF_OUT)},
}}
adventure_ginger.lua flies a Skiff this way. The atom kinds are in Lua API reference.
Play an actor animation
local actor = context:slot(Slot.SQ_BOSS_BOSS)
local sequence = actor:sequences().BERSERK_ANIMATION
if sequence ~= nil and sequence.playable then
actor:play_sequence{sequence = sequence}
end
Walk actor:sequences() with count and at(n) to see what an actor has.
Remove an actor
context:slot(Slot.SQ_ESCORT_ESCORT):retire_actor()
Send a command to every member of a squad
context:squad(Squad.SQ_ALLIES):actor_command{
command = mission.ActorCommand.COMMAND_3,
value = 0,
}
commandis amission.ActorCommandvalue.valueis an int32.- The command names and what they do come from the game data. The names above are placeholders.
mission.Factionholds the special faction values a set-faction command takes.- No current script uses
actor_command. Test it in game before you rely on it.
Flow
Act on a request’s result
local function start_move(context)
local key = context:select_state(mission.states.STATE_BOSS_ROOM)
context:set_variable("move.request", key.value)
end
return {
on_event_effect_result = function(context, state, event)
if event.request_key.value ~= state:variable("move.request") then return end
context:clear_variable("move.request")
if event.outcome ~= "transport_staged" then
-- refused, expired or canceled: try another way
end
end,
}
Results arrive after the whole request queue is empty.
Build the mission as steps
Use lib.flow. Each step waits for facts, then runs once. The full reference is in The shared script libraries. A small example:
local flow = require("lib.flow")
local graph = flow.new{key = "m", facts = {
{id = "entered", observe = function(context, _, event)
return lib.is_slot(context, event, Slot.PT_CAVE)
end},
}, steps = {
{id = "go", run = function(context)
context:slot(Slot.PT_CAVE):fire_trigger()
end, await = flow.fact("entered")},
{id = "fight", after = {"go"}, run = function(context)
context:squad(Squad.SQ_CAVE):place{}
end, await = function(context)
return context:cohort{squads = {Squad.SQ_CAVE}}.cleared
end},
{id = "done", after = {"fight"}, run = function(context)
context:complete_mission{}
end},
}}
return {
on_start = function(context, state) graph:advance(context, state) end,
on_load = function(context, state) graph:advance(context, state) end,
on_event_player_trigger = function(c, s, e) graph:handle(c, s, e) end,
on_event_squad_state = function(c, s, e) graph:handle(c, s, e) end,
}
For a linear story mission, lib.campaign builds all of this from a content table.
Run several areas in one script
Give each area its authored state and its content. Build an area when the player first reaches its region, and store a mark so a return visit does not place everything again. scripts/city_tower_social_d2/city_tower_social_d2.lua does this:
local AREAS = {
{state = mission.states.STATE_X, squads = lib.list(Squad.SQ_A, Squad.SQ_B),
objects = lib.list(Slot.O_CRATE)},
{state = mission.states.STATE_Y, squads = lib.list(Squad.SQ_C), objects = {}},
}
local function area_for(region)
for _, area in ipairs(AREAS) do
if area.state.region_index == region then return area end
end
return nil
end
return {
on_event_region_changed = function(context, state, event)
local area = area_for(event.region_index)
if area == nil then return end
local built = "built_" .. area.state.region_index
if state:variable(built) then return end
context:set_variable(built, true)
-- Squads first, then the state; objects and scenes bind under the new state.
lib.place_all(context, area.squads, context.sdk.squad_modes.reinforce)
context:select_state(area.state)
context:activate_objects{slots = area.objects, active = true}
end,
}
When areas run timers, keep each area’s variables and timers under its own tag with lib.scope. Stop an area’s timers when the player leaves it, so no request reaches an area the client does not hold. For an open-world destination, lib.freeroam does all of this. See The shared script libraries.
Wipe and restart at a checkpoint
Only a private activity can do this. The whole party must be dead.
on_event_fireteam_state = function(context, state, event)
local wiped = event.dead_count > 0 and event.alive_count == 0 and event.unknown_count == 0
if not wiped or state:variable("wipe.request") ~= nil then return end
local key = context:restart_checkpoint{region = OUTPOST_REGION, spawn_set_hash = OUTPOST_SPAWN}
context:set_variable("wipe.request", key.value)
end,
on_event_effect_result = function(context, state, event)
local request = state:variable("wipe.request")
if request == nil or event.request_key.value ~= request then return end
context:clear_variable("wipe.request")
if event.outcome ~= "transport_staged" then return end
-- the new attempt has started; release the client's wait
context:restart_checkpoint{
region = OUTPOST_REGION, spawn_set_hash = OUTPOST_SPAWN, release_request = request,
}
-- reset the area: despawn squads, turn off darkness, and so on
end,
- An accepted arm starts a new attempt. Timers are cleared. Variables stay.
lib.flowcheckpoints replay the steps after the checkpoint on the new attempt.adventure_ginger.luahas the full pattern, including waiting for the respawn.
Finish the mission
context:slot(Slot.M_DIRECTIVE_SENSOR_X):clear_directives()
context:complete_mission{}
complete_mission works once per attempt. context.mission_complete tells you if it already ran.
To set another lifetime state:
context.lifetime:set{state = context.sdk.lifetime_states:at(4)}
Players
See who else is here
for index = 1, context.peers.count do
local peer = context.peers:at(index)
-- peer.session_id, peer.member_key, peer.join_identity
end
on_event_session_joined and on_event_session_left fire when the list changes.
Know when the party is alive or dead
on_event_fireteam_state = function(context, state, event)
-- event.alive_count, event.dead_count, event.unknown_count
end,
This fires only in private activities. It is sent again after a new attempt.
