Create / Modding reference / Events
Events
Everything that can fire a handler. A handler's name: field names one of
the SIXTEEN event kinds below, written bare (they are unit variants):
name: OnStart, name: OnEnter, and so on. When the event fires, the
handler's filters gate it and its actions run.
A handler that describes a beat happening ONCE says
once: true beside its
name, and the engine retires it the first time its filters pass. Every
repeating event below composes with it.
An event name also appears inside a
Sequence step's until gate, where it names the
event a paced beat waits for. The filters that qualify it are the same ones.
The whole vocabulary at a glance:
| event | payload | fires when |
|---|---|---|
OnStart |
none | once, right after the scenario loads |
OnUpdate |
none | every frame while live, unpaused and fully spawned |
OnTimerEnd |
key |
a keyed scenario timer ends |
OnDefeated |
id, type_name |
a ship is neutralized or directly destroyed |
OnDestroyed |
id, type_name |
a scenario object is physically destroyed |
OnNeutralized |
id, type_name |
an armed ship loses ALL weapons, or the flight computer it had |
OnEnter |
id, other_id, other_type_name |
a body enters a trigger area |
OnExit |
id, other_id, other_type_name |
a body leaves a trigger area |
OnOrbitStart |
id, other_id, other_type_name |
an ORBIT maneuver starts |
OnOrbitStable |
id, other_id, other_type_name |
ORBIT enters stable station-keeping |
OnOrbitUnstable |
id, other_id, other_type_name |
stable station-keeping is lost |
OnOrbitEnd |
id, other_id, other_type_name |
a surviving ship ends ORBIT |
OnTravelLockStart |
id, other_id, other_type_name |
the player's travel lock lands |
OnTravelLockEnd |
id, other_id, other_type_name |
the player's travel lock leaves |
OnCombatLockStart |
id, other_id, other_type_name |
the player's combat lock lands |
OnCombatLockEnd |
id, other_id, other_type_name |
the player's combat lock leaves |
Entity payload fields are what an Entity filter can match: id /
type_name name the event's SUBJECT, other_id / other_type_name its other
party. OnTimerEnd instead carries key, matched by a Timer filter. Which
entity is which is per-event and listed below. A filter field the event does
not fill NEVER matches - other_id on an OnDestroyed handler can never pass.
OnStart
Fires exactly once, right after the scenario loads - after every handler entity exists, so no handler can miss it. Carries no payload; this is where a scenario seeds its world: spawns, lights, variable seeds, the first objective.
(
name: OnStart,
actions: [
VariableSet((key: "beat", expression: Term(Factor(Literal(Number(0.0)))))),
// ... spawns, lights, first objective ...
],
),
Show explanation
An Entity filter never matches OnStart (no payload). Seed every variable
your expression filters will read (they
fail closed on unset variables), and post
the first objective here.
Its spawns arrive over the next few frames, and the scenario is HELD until they
have: no other handler runs, the scenario clock does not tick, and the LOADING
panel stays up. So the first OnUpdate after OnStart already sees every
object OnStart asked for - a count gate cannot read a half-built world.
OnUpdate
Fires every frame while the scenario is live and UNPAUSED (frozen behind the
pause menu and the outcome overlay) and every object it asked for exists.
Carries no payload - and an unfiltered OnUpdate handler runs its actions
EVERY frame, so always gate it.
Show explanation
The chain order is guaranteed: the scenario clock ticks, typed queries and
watches update, ended timers fire, then OnUpdate fires. Query-backed gates
see one coherent frame snapshot.
This is the workhorse for clock-driven beats and count thresholds that must
not depend on handler order. Gate it with Expression filters, and add
once: true when the beat
happens one time - then the only filter left is the one about the game:
(
name: OnUpdate,
once: true,
filters: [
Expression((GreaterThan(
Term(Factor(Name("scenario_elapsed"))),
Term(Factor(Literal(Number(10.0)))),
))),
],
actions: [
StoryMessage((
speaker: "Control",
text: "Ten seconds elapsed.",
)),
],
),
A beat that genuinely repeats leaves once off and re-arms itself in its own
actions - see the recipes.
OnTimerEnd
Fires exactly once when a keyed scenario timer reaches its deadline. Payload:
key is the scenario-local timer key - match it with a
Timer filter.
(
name: OnTimerEnd,
filters: [Timer((key: "briefing_delay"))],
actions: [StoryMessage((speaker: "Control", text: "Proceed."))],
),
Show explanation
Timer-end events queue before that frame's OnUpdate pulse.
Start or restart the delay with TimerStart. Cancel
it with TimerCancel. Timers use live, unpaused
scenario time and clear on retry or teardown.
OnDefeated
Fires exactly once when a ship leaves combat through neutralization or direct
physical destruction. Payload: id and type_name of the defeated ship - the
event for kill objectives and encounter progression that do not care whether a
wreck remains.
(
name: OnDefeated,
filters: [Entity((id: Some("raider")))],
actions: [ /* complete the encounter once */ ],
),
Show explanation
Ordering is fixed:
- Neutralization:
OnDefeated, thenOnNeutralized. - Direct ship destruction:
OnDefeated, thenOnDestroyed. - Later destruction of an already-neutralized wreck:
OnDestroyedonly.
Scripted despawn, scenario teardown, and boundary cleanup fire none of these edges.
OnDestroyed
Fires when a scenario object is physically destroyed: an asteroid breaks, or a
ship dies through the section-explosion pipeline. Payload: id and
type_name of the DESTROYED object; there is no other party.
(
name: OnDestroyed,
filters: [ Entity((type_name: Some("asteroid"))) ],
actions: [ /* bump a counter */ ],
),
Show explanation
Type names are the object-kind constants: "anchor", "asteroid",
"spaceship", "beacon", "salvage_crate", "light" - see
Scenario objects.
OnNeutralized
Fires when a ship that was ARMED loses all working weapons, or loses the
flight computer it once had - combat-dead, hull possibly intact, still in the
world. Payload: id, type_name of the neutralized ship; no other party.
(
name: OnNeutralized,
filters: [Entity((id: Some("derelict_gunship")))],
actions: [
ObjectiveComplete((id: "disarm_gunship")),
StoryMessage((speaker: "Control", text: "Guns down. The wreck is yours.")),
],
),
Show explanation
A brain-dead ship cannot aim or fly, whatever else survives; thrusters play
no part in the rule. The ship is NOT despawned, so no OnDestroyed fires
with it. A ship that never had a computer (a bare emplacement) only
neutralizes by losing its guns.
Use OnNeutralized only when the persistent-wreck distinction matters. Use
OnDefeated for the shared combat outcome.
OnEnter
Fires when a body's FIRST collider makes contact with a trigger area
(occupancy is refcounted per body, so a multi-section ship fires it once, on
the 0-to-1 transition). Payload: id is the AREA; other_id /
other_type_name are the ENTERING body.
Match one area and one specific entering ship:
(
name: OnEnter,
filters: [
Entity((
id: Some("safe_zone"),
other_id: Some("player_spaceship"),
)),
],
actions: [ /* player arrived */ ],
),
Show explanation
Three things produce trigger areas: the
CreateScenarioArea action, a
Beacon with area_radius set, and every
SalvageCrate (its area_radius is the pickup
sensor). All three report under their own id.
Or accept any spaceship that enters that area by filtering the other party's type:
(
name: OnEnter,
filters: [
Entity((
id: Some("repair_zone"),
other_type_name: Some("spaceship"),
)),
],
actions: [ /* a ship entered */ ],
),
Notes:
- The entering body must itself be a scenario object (carry an id and type name) to be reported.
- An area created AROUND a body already inside it still fires
OnEnter- the fresh overlapping pair counts as an entry. So arming a zone late, or spawning it on top of the player, works. (This was once not true; do not design around the old behavior.) OnEnteris a plain event, not a state: if the handler's variable gate is not open yet when the body enters, the entry is consumed and will NOT re-fire when the gate opens later. Place areas so the entry happens after the gate opens, or gate on a variable the repeat pulses can re-check (OnUpdate+ occupancy variables you maintain yourself).
OnExit
The complement: fires when a body's LAST collider leaves the area (the
1-to-0 transition). Same payload shape as OnEnter.
(
name: OnExit,
filters: [
Entity((
id: Some("repair_zone"),
other_id: Some("player_spaceship"),
)),
],
actions: [ /* player left the repair zone */ ],
),
Show explanation
A body despawned while inside an area fires NO OnExit for itself - its
occupancy rows are pruned silently.
Use other_type_name: Some("spaceship") instead of other_id when every ship
leaving the area should match.
Orbit lifecycle
Four one-shot edge events describe ORBIT without hidden timing:
OnOrbitStart, OnOrbitStable, OnOrbitUnstable, OnOrbitEnd. All four
carry id = well and other_id / other_type_name = orbiting ship.
Show explanation
OnOrbitStart: the maneuver engages for a well. The ship may still be aligning or burning toward its ring.OnOrbitStable: velocity error enters the autopilot's stable Hold band. It can fire again after stability is recovered.OnOrbitUnstable: velocity error leaves Hold while ORBIT stays engaged.OnOrbitEnd: a surviving ship cancels ORBIT, changes verb, loses flight capability, loses the well, or switches wells. Ship destruction emits onlyOnDestroyed, consistent with area despawn not emittingOnExit.
Switching wells queues OnOrbitEnd for the old well, then OnOrbitStart for
the new one. Ending a stable orbit emits only OnOrbitEnd, not an unstable
edge first.
A continuous eight-second stable hold uses a timer:
(
name: OnOrbitStable,
filters: [Entity((id: Some("planetoid"), other_id: Some("player_spaceship")))],
actions: [TimerStart((key: "orbit_hold", seconds: Term(Factor(Literal(Number(8.0))))))],
),
(
name: OnOrbitUnstable,
filters: [Entity((id: Some("planetoid"), other_id: Some("player_spaceship")))],
actions: [TimerCancel((key: "orbit_hold"))],
),
(
name: OnOrbitEnd,
filters: [Entity((id: Some("planetoid"), other_id: Some("player_spaceship")))],
actions: [TimerCancel((key: "orbit_hold"))],
),
(
name: OnTimerEnd,
filters: [Timer((key: "orbit_hold"))],
actions: [ObjectiveComplete((id: "hold_orbit"))],
),
Lock lifecycle
Player locks expose four one-shot edges: OnTravelLockStart /
OnTravelLockEnd for the travel (white, navigation) lock landing on and
leaving its target, OnCombatLockStart / OnCombatLockEnd for the combat
(red) lock. All four carry the locked target as id and the locking player
ship as other_id / other_type_name.
Show explanation
A held lock stays quiet. AI locks do not fire scenario events. A direct target switch queues end for the old target, then start for the new target.
(
name: OnTravelLockStart,
filters: [Entity((
id: Some("anchorage"),
other_id: Some("player_spaceship"),
))],
actions: [
VariableSet((key: "surveyed", expression: Term(Factor(Literal(Number(1.0)))))),
// ... the survey beat ...
],
),
(
name: OnTravelLockEnd,
filters: [Entity((
id: Some("anchorage"),
other_id: Some("player_spaceship"),
))],
actions: [
// ... react to losing the survey target ...
],
),
Use the combat pair for the red lock. To react to any locked target, omit
id; keep other_id when the locking ship must be player_spaceship.
Dispatch order (what you can rely on)
- Events queue and drain FIFO within a frame; for each event, handlers run in AUTHORED order; within a handler, actions run in authored order.
- Actions mutate the event world immediately (a later filter in the same
frame sees the new variable values), but their WORLD effects (spawns,
despawns) land together at the end-of-frame sync - and an
ObjectiveMarkerAttachordered after aSpawnScenarioObjectin the same handler does see the fresh object. - Never depend on handler order across DIFFERENT events; gate on variables.