Create / Modding reference / Events

Events

Everything that can fire a handler. A handler's name: field names one of the FORTY-FIVE 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
OnResume none once, right after a saved world loads, instead of OnStart
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, type_name, other_id, other_type_name two hulls clamp together
OnUndocked id, type_name, 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
OnMined id, other_id, other_type_name, item, count a mining pulse frees ore from a rock
OnPickedUp id, type_name, canister, item, count a cargo intake takes whole items of a canister stack into the hold
OnJettisoned id, type_name, canister, item, count a cargo intake drops a stack out as a canister
OnTraded id, other_id, other_type_name, trade, item, count, price_cr the player buys or sells a stack across a dock
OnCreditsChanged id, type_name, previous_cr, current_cr, delta_cr, cause a ship's credits move
OnRepaired id, type_name, section, item, count the Ship pane spends plates on a section
OnRebuilt id, type_name, section, parts, price_cr the Ship pane rebuilds a lost section at a docked station
OnSwapped id, type_name, section, from, to, parts, price_cr the Ship pane swaps a section for another part at a docked station
OnAdded id, type_name, section, part, at, point, parts, price_cr the Ship pane adds a part at a free socket at a docked station
OnShipBought id, type_name, hull, price_cr the Station pane buys a whole hull from a docked station
OnEscortHired id, type_name, escort, hull, price_cr the Ship pane hires an escort from a docked station into the fleet
OnStationBought id, type_name, station, price_cr the Ship pane buys the docked station from its civilization
OnStripped id, type_name, hull, section, parts an Inventory pane strip takes a section off a docked wreck, once per section, the berth port last; or a Ship pane delete takes a section off the ship itself
OnStored id, other_id, other_type_name, item, count the player stores a stack in a docked station's storage
OnWithdrawn id, other_id, other_type_name, item, count the player withdraws a stack from a docked station's storage, or Take all or Strip takes one from a dead station's
OnJobAccepted id the Jobs pane accepts a board offer; id is the job's id; a refused accept and a Load fire none

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.

OnResume

Fires exactly once, instead of OnStart, when a saved world loads. By then the saved scenario state is already back: the variables, the objectives, the story log, the readouts, the running timers and the generated marks. A once handler that fired before the save is not rebuilt, so what it did is not done twice. Carries no payload.

Only a scenario with world: Generated((seed: None)) saves, and lint requires its OnResume to spawn exactly one player ship. Hang the same spawn list on both openings - the ship and the lights - and keep the seeds and the first objective on OnStart alone: the save brings those back.

(
    name: OnResume,
    once: true,
    actions: [
        // ... the same player spawn and lights as OnStart ...
    ],
),
Show explanation

The save does not keep the player ship as a spawn: the open world streams around the ship OnResume spawns, and the save's ship state lands on it once it stands. A VariableSet here would overwrite the saved value, and an Objective would post a second copy of one the save already lists.

OnResume on a scenario that cannot be saved is a lint warning: it never fires there.

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.

A generated world's bounds block (see World) is an area named bounds the loader spawns before OnStart, so id: Some("bounds") here is how a scenario answers the player leaving it.

In a generated world the entering body may be one the generator placed - a rock, a derelict - whose id (sector_n2_0_0_background_rock_3) exists only at runtime. You cannot write that id in a filter or an action (lint refuses it); pass it on from the event instead with ObjectiveMarkerAttach((target_id: "$other_id", ..)).

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 hull that was docked WITH as id / type_name 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. Its type_name tells a station from a ship: a living station reads living_station, a dead one derelict_station (spawned dead, or a living one that lost its last turret), and any other hull spaceship. That is how a beat asks for "any living station" in a generated world, where no author can write the station's id:

(
    name: OnUndocked,
    once: true,
    filters: [Entity((
        type_name: Some("living_station"),
        other_id: Some("player"),
    ))],
    actions: [
        Objective((id: "find_help", message: "Find another living station")),
    ],
),

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.

Hold events

Fifteen edges report what a ship's hold and purse do. Each fires ONCE per occurrence from the system that did the work, never from a poll of the inventory, so a handler counts occurrences, not frames.

event id is other_id is extra fields
OnMined the rock the mining ship item, count (corners the pulse freed)
OnPickedUp the ship whose intake took the stack none canister, item, count
OnJettisoned the ship whose intake dropped the stack none canister, item, count
OnTraded the ship traded WITH the player's ship trade (Buy or Sell, from the player's side), item, count, price_cr
OnCreditsChanged the ship whose purse moved none previous_cr, current_cr, delta_cr (signed), cause (Trade, Take, Rebuild, Swap, Add, ShipBuy, EscortHire, StationBuy, JobReward, Settle or Cheat)
OnRepaired the repaired ship none section (the Ship pane code, CTL-1), item, count (plates spent)
OnRebuilt the rebuilt ship none section (the code the lost section had, PDC-1), parts (every recipe part, item id to count), price_cr (work plus bought parts)
OnSwapped the refitted ship none section (the Ship pane code, PDC-1), from and to (the old and new section prototype ids), parts (every part of the new part's recipe), price_cr (work plus bought parts)
OnAdded the refitted ship none section (the new section's Ship pane code, PDC-3), part (its section prototype id), at and point (the code and socket id it mounts on), parts (every part of its recipe), price_cr (work plus bought parts)
OnShipBought the ship that bought none hull (the parked hull's name, Vel civilian Mk 1), price_cr (every section's recipe at ask, paid in full). A free Board of a ship the player owns fires none
OnEscortHired the ship that hired none escort (the escort's id, sector_1_0_0_cluster_1_0_0_station_0_escort_0), hull (the catalogue name, Vel armored escort 1), price_cr (the hull's price times 3/2)
OnStationBought the ship that bought none station (the station's id, sector_0_n1_0_cluster_0_n1_0_station_0), price_cr (its sections, hold and credits at ask, times 2)
OnStripped the ship that stripped none hull (the wreck's id, or the ship's own id for a delete), section (the stripped section's code on that hull, PDC-1; a strip's last is the port the player docked through), parts (every part the strip put in the hold, item id to count)
OnStored the station whose storage took the stack the player's ship item, count
OnWithdrawn the station whose storage gave the stack, a dead one looted by Take all or Strip included the player's ship item, count
Show explanation

The pair events follow the docking rule: the PASSIVE party is id and the ship that acted is other_id. A rock is mined, a tender is traded with, a station is stored at. The single-ship events name the ship the hold or purse belongs to.

OnMined fires per paying pulse: a pulse that flips no corner is silent, and count is the corners it did flip, which is the ore that lands in the hold only if the intake catches the chips. OnPickedUp and OnJettisoned fire once per STACK: a canister holding two item kinds raises two events with the same canister. A hold with room for only part of a canister takes whole items and raises OnPickedUp with the count it took; the rest is picked up later under the same canister.

OnTraded fires once per confirmed trade on the player's queue, so a buy of four rations is one event with count: 4. The credits move it causes raises one OnCreditsChanged per party, cause: Trade, in the same frame; the loot-take of a dead ship's purse raises one with cause: Take; a rebuild raises one per party with cause: Rebuild, the ship paying and the station paid, a swap one per party with cause: Swap, an add one per party with cause: Add, a hull buy one per party with cause: ShipBuy, and an escort hire one per party with cause: EscortHire. A station buy raises one, for the player only, with cause: StationBuy: the price goes to the civilization, so the station's purse does not move. An armed credits give or credits set command raises one with cause: Cheat. A move that leaves the purse unchanged raises nothing. OnStored and OnWithdrawn fire once per confirmed move and move no credits, so they raise no OnCreditsChanged.

Trades, stores, withdraws, repairs, rebuilds, swaps, adds, hull buys, escort hires and station buys come from the interface panes, which run while the world is paused: their events arrive on the first simulation tick after the pane closes. Intake and mining events arrive the frame the hold changes.

A prospecting berth that wants one of each:

(
    name: OnTraded,
    once: true,
    filters: [Entity((id: Some("example_tender"), other_id: Some("player_spaceship")))],
    actions: [ObjectiveComplete((id: "prospect_trade"))],
),
(
    name: OnMined,
    once: true,
    filters: [Entity((id: Some("example_ore_rock"), other_id: Some("player_spaceship")))],
    actions: [ObjectiveComplete((id: "prospect_mine"))],
),
(
    name: OnRepaired,
    once: true,
    filters: [Entity((id: Some("player_spaceship")))],
    actions: [ObjectiveComplete((id: "prospect_repair"))],
),

The example mod's example_prospect scenario runs all but OnRebuilt, OnSwapped, OnAdded, OnShipBought, OnEscortHired, OnStationBought, OnStripped, OnStored and OnWithdrawn.

OnJobAccepted

Fires once per successful accept from the Jobs pane. Payload: id only, and it is the JOB's id, not an entity id. There is no type_name and no other_id.

(
    name: OnJobAccepted,
    once: true,
    actions: [ObjectiveComplete((id: "take_a_job"))],
),
Show explanation

An Entity filter on id matches a job id here, not a scenario object. A job id reads <station id>#<slot>.<posting> for a slot job and <station id>#<visit id>.<index> for a visit posting.

A refused accept fires nothing, and neither does a Load that restores held jobs, so a handler on this event never runs twice for one job.

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.
  • OnTraded and its two OnCreditsChanged land on the same tick, trade first; which party's credits event runs first is not promised.