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:

  • MoveShipTo and StopShip complete when the ship's autopilot lets go - inside the standoff, or at rest.
  • ForceAlign completes when the aim is inside its authored tolerance and steady there.
  • PatrolShip completes at the END of its one loop, back where it started - not at each waypoint.
  • OrbitShip completes 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, then OnNeutralized.
  • Direct ship destruction: OnDefeated, then OnDestroyed.
  • Later destruction of an already-neutralized wreck: OnDestroyed only.

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.)
  • OnEnter is 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 only OnDestroyed, consistent with area despawn not emitting OnExit.

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 ObjectiveMarkerAttach ordered after a SpawnScenarioObject in the same handler does see the fresh object.
  • Never depend on handler order across DIFFERENT events; gate on variables.