Create / Modding reference / Events
Events
Everything that can fire a handler. A handler's name: field names one of
the TWENTY-EIGHT 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 |
OnCinematicFinished |
cinematic |
a scene ends, by ANY path |
OnCinematicSkipped |
cinematic |
the player leaves a scene early |
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 |
OnGotoComplete |
id, other_id, other_type_name |
the player's GOTO reaches its target and stops |
OnStopComplete |
id, type_name |
the player's STOP ends |
OnOrbitStart |
id, other_id, other_type_name |
an ORBIT maneuver starts |
OnOrbitStable |
id, other_id, other_type_name |
ORBIT enters stable station-keeping |
OnOrbitLap |
id, other_id, other_type_name |
one net revolution of the ring completes |
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 |
OnDocked |
id, other_id, other_type_name |
two hulls clamp together |
OnUndocked |
id, other_id, other_type_name |
a dock comes off, by any ending |
OnShipOrderComplete |
order, kind, id, type_name |
a helm order reaches what it was told to reach |
OnShipOrderInterrupted |
order, kind, id, type_name |
an AI ship breaks off its order to fight |
OnShipOrderResumed |
order, kind, id, type_name |
that ship picks the same order back up |
OnShipOrderCanceled |
order, kind, id, type_name |
an unfinished order is cleared or replaced |
OnShipOrderFailed |
order, kind, id, type_name |
an order can no longer be flown |
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; the two
cinematic endings carry cinematic, matched by a
Cinematic filter; and the five
ship-order events carry order / kind for a
ShipOrder filter beside the ship's own id /
type_name - the same four fields on all five, so one filter matches
whichever of them you hang it on. 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: [
NarrativeCue((
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: [NarrativeCue((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.
Cinematic endings
A Cinematic does not carry an on-finish action list.
It REPORTS its ending, and the handlers that answer live beside it. Payload:
cinematic is the scene's authored key - match it with a
Cinematic filter.
// Every path out: give back what the scene took.
(
name: OnCinematicFinished,
filters: [Cinematic((key: "strike_approach"))],
actions: [
ReleaseCamera(()),
ResumePlayerControl(()),
],
),
// Only the skip: what the beats that never played would have left behind.
(
name: OnCinematicSkipped,
filters: [Cinematic((key: "strike_approach"))],
actions: [
DespawnScenarioObject((target_id: "warship")),
VariableSet((key: "beat", expression: Term(Factor(Literal(Number(6.0)))))),
],
),
Show explanation
OnCinematicFinished fires on EVERY way out of the scene - its last beat ran,
the player skipped it, a CancelCinematic
stopped it, or one of its steps blew its deadline. Author the post-state there,
once, and no path can forget it.
OnCinematicSkipped fires FIRST on a skip, immediately before that scene's
OnCinematicFinished. It carries only the catch-up: the actors the unplayed
beats would have spawned, moved or destroyed, and the variables they would have
latched. It is not a second copy of the scene - a skip means the player asked
not to watch, so nothing presentational belongs here.
Always filter by key. A scenario with two scenes and an unfiltered handler
answers whichever one ends first. A Cinematic filter naming a key no scene
plays is a lint Error, so a typo costs the whole scenario, not one handler.
Ship order lifecycle
The five events below report what became of a
helm order. They carry the SAME four fields -
order is the key the action named the order, kind is which of the five it
was (Move / Align / Stop / Patrol / Orbit), and id / type_name
are the ship - so one ShipOrder filter matches
whichever of them a handler listens for, and an Entity filter still works on
all of them.
(
name: OnShipOrderComplete,
filters: [ShipOrder((order: Some("close_the_gap")))],
actions: [ForceRailgunFire((ship: "warship", section: "spinal"))],
),
Show explanation
An order that is REFUSED when it is issued - a dangling ship id, a player's ship, an empty patrol route, an impossible tolerance - fires nothing at all. It was never installed, so there is nothing to report on. The lint catches each of those before the scenario runs.
A key is reusable: give the same order again and the lifecycle runs again.
OnShipOrderComplete
Fires exactly once when an order reaches what it was told to reach.
Show explanation
What "reached" means is per order:
MoveShipToandStopShipcomplete when the ship's autopilot lets go - inside the standoff, or at rest.ForceAligncompletes when the aim is inside its authored tolerance and steady there.PatrolShipcompletes at the END of its one loop, back where it started - not at each waypoint.OrbitShipcompletes when the ring is ESTABLISHED, and then keeps holding it.
Completion says the CONDITION was met, not that the behavior stopped. Move,
Stop and Patrol release the helm as they report; Align and Orbit keep
holding until something else takes it.
OnShipOrderInterrupted
Fires when an AI ship breaks off its order to fight. Only an AI ship can, and
only one whose order_interruption policy says so (see
Spaceship); the default policy is never, and a
None-controller ship has no bot to break off in the first place.
(
name: OnShipOrderInterrupted,
filters: [ShipOrder((ship: Some("picket")))],
actions: [NarrativeCue((speaker: "Picket", text: "Contact. Breaking off."))],
),
Show explanation
The order is NOT lost. It is parked: the ship keeps the key, the kind and its
place in the route, the AI takes the helm back and fights, and when the signal
clears the same order picks up from where it stopped - see
OnShipOrderResumed.
A beat gated on the COMPLETION still waits, correctly: an interrupted patrol has not swept the belt yet.
OnShipOrderResumed
Fires when an interrupted order is picked back up - the hostile is gone, or the damage has stopped, depending on the ship's policy.
Show explanation
Pairs one-for-one with
OnShipOrderInterrupted, and can pair with it
several times over one order's life. The resumed order flies its OWN
directive, from the leg it was on - not a fresh copy from the top.
OnShipOrderCanceled
Fires when an unfinished order is called off: either
ClearShipOrder, or a second helm order
replacing it.
Show explanation
Cancellation is not completion, and a beat gated on the completion correctly never runs. This is the event for the OTHER side of that: cleaning up a readout, or telling the player the approach was called off.
An order that had already reported a terminal outcome says nothing more, so
clearing a ship that is holding a finished
ForceAlign is silent - the align already
completed.
OnShipOrderFailed
Fires when an order can no longer be flown. The hull lost the flight computer
or the drives the order runs on, or no single loaded well answers an
OrbitShip target, or the well is lost or will not
hold a ring before the ring is established.
Show explanation
This is the event that keeps a Sequence gate from waiting forever on a beat
that can never land. A wreck does not arrive.
It reports the ORDER, not the ship: a hull that is destroyed outright is
OnDestroyed and OnDefeated, which is what a
kill objective should be waiting on. Failure is for the ship that is still
there and can no longer do the job.
Weapon actions have no lifecycle at all - what a shot produces is the weapon's own event chain.
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",
"planet", "spaceship", "beacon", "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")),
NarrativeCue((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
Two things produce trigger areas: the
CreateScenarioArea action and a
Beacon with area_radius set. Both report under their
own id. Entering an area moves no cargo.
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.
Player maneuver completion
OnGotoComplete and OnStopComplete report successful terminal conditions
from the player's real autopilot. They do not fire when manual input cancels a
maneuver, a target disappears, or the ship loses the ability to continue.
Scripted and AI ship orders use the separate
OnShipOrderComplete lifecycle.
OnGotoComplete carries the reached target as id and the player ship as
other_id / other_type_name. The target still exists while the handler runs,
so this is the safe event on which to remove a temporary navigation beacon:
(
name: OnGotoComplete,
once: true,
filters: [Entity((
id: Some("transit_mark"),
other_id: Some("cutter"),
))],
actions: [
ObjectiveComplete((id: "transit")),
DespawnScenarioObject((id: "transit_mark")),
],
),
OnStopComplete carries the braked player ship as id / type_name. It
reports the maneuver ending, which is a deadband and not a dead stop - a drift
the drive is not pointing at is released at up to 7.5 m/s - so a beat that needs
the hull genuinely still should gate on a
Speed query as well:
(
name: OnStopComplete,
once: true,
filters: [Entity((id: Some("cutter")))],
actions: [ObjectiveComplete((id: "come_to_rest"))],
),
A GOTO that reaches a gravity well and transitions directly into a viable ORBIT does not complete as GOTO; use the orbit lifecycle for that maneuver.
Orbit lifecycle
Five events describe ORBIT without hidden timing: OnOrbitStart,
OnOrbitStable, OnOrbitLap, OnOrbitUnstable, OnOrbitEnd. All five 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. -
OnOrbitLap: the ship accumulates one net revolution in the planned travel direction. It fires again for each later complete lap.Counting starts when the ship first reaches the ring, not when the verb engages: the approach curves around the well and would otherwise bank most of a lap before the orbit began. From there it survives everything the autopilot does on the ring - an ORBIT that drops out of the stable band is correcting, not leaving - and only an absence of five seconds or more writes the partial lap off and starts it again at the ring. A lap objective therefore does not punish a player for one nudge.
-
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 mission that requires one physical lap listens directly:
(
name: OnOrbitLap,
once: true,
filters: [Entity((id: Some("planetoid"), other_id: Some("player_spaceship")))],
actions: [ObjectiveComplete((id: "orbit_once"))],
),
A continuous eight-second stable hold uses a timer instead:
(
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.
Docking lifecycle
Two edges describe a dock: OnDocked when the clamp is made and OnUndocked
when it comes off. Both carry the ship that was docked WITH as id and the
ship that ASKED for the dock as other_id / other_type_name, and the release
keeps the roles the capture set - so one pair of ids filters both edges.
Show explanation
DOCK is a player verb, so other_id is the player's ship and id is
whatever it flew up to.
OnDocked fires when the clamp exists, which is before the sleeves finish
reaching across the gap - the joint is what holds the pair, and the tubes are
art that follows it.
OnUndocked fires for EVERY ending, and carries no reason code: either pilot
pressing DOCK again, a shot-off docking port, or one of the two ships dying. What a beat owes the pair
back - the objective, the next order, the clearance to leave - is the same
whichever way the clamp came off, and a scenario that must tell the endings
apart has OnDestroyed to do it with.
Only a pair that dies WHOLE stays quiet, the way OnExit and OnOrbitEnd do
for a despawned body. One hull dying is a release like any other: the survivor
is free, and it is told so.
A delivery beat that opens when the player clamps on and closes when it lets go:
(
name: OnDocked,
once: true,
filters: [Entity((
id: Some("supply_tender"),
other_id: Some("player_spaceship"),
))],
actions: [
ObjectiveComplete((id: "make_contact")),
Objective((id: "carry_cargo", message: "Objective: Run the cargo home")),
],
),
(
name: OnUndocked,
filters: [Entity((
id: Some("supply_tender"),
other_id: Some("player_spaceship"),
))],
actions: [
VariableSet((key: "cargo_aboard", expression: Term(Factor(Literal(Number(1.0)))))),
],
),
To react to a dock with anything, omit id and keep other_id.
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.