Create / Modding reference / Filters

Filters

A filter gates a handler: when its event fires, EVERY entry in the handler's filters list must pass (logical AND) before the actions run. An empty (or omitted) list always passes. There are exactly six filter kinds - Entity matches entity payloads, Timer matches a timer key, Cinematic matches a scene key, ShipOrder matches a helm order's outcome, Expression tests scenario variables, and Conditional combines other filters with boolean logic.

filter tests typical use
Entity who the event is about "this beacon, entered by the player"
Timer a timer event key "the orbit hold timer ended"
Cinematic a scene key "the strike approach is the scene that ended"
ShipOrder one helm order's outcome "the warship reached its firing position"
Expression a variable condition "the counter is past 4 and the flag is unset"
Conditional other filters, combined "NOT the player", "picket A down OR picket B down"

Entity

Match the identity fields the event carries. Every field is optional; every SET field must match exactly (string equality); an unset field is unconstrained.

// the beacon_1 area, entered specifically by the player ship
Entity((id: Some("beacon_1"), other_id: Some("player_spaceship")))

// any asteroid
Entity((type_name: Some("asteroid")))

// any event that carries entity data at all
Entity(())
Show explanation
field type default matches
id Option string None the event subject's id
type_name Option string None the subject's object kind ("anchor", "asteroid", "planet", "spaceship", "beacon", "light")
other_id Option string None the other party's id
other_type_name Option string None the other party's object kind

Which entity is the subject and which is the other party is per-event - the payload tables in the Events reference are the source of truth. The quick map:

event id / type_name (subject) other_id / other_type_name
OnDefeated, OnDestroyed, OnNeutralized the defeated / destroyed / neutralized object (none)
OnEnter / OnExit the AREA (zone or beacon) the body that entered / left
Orbit lifecycle events the well being orbited the orbiting ship
travel/combat lock start/end the locked target the locking player ship
OnDocked / OnUndocked the hull that was docked with the hull that asked for the dock
OnStart / OnUpdate (no payload - an Entity filter never matches) (none)

Two rules that bite:

  • Absent field = never matches. A set filter field whose key the event does not fill fails the filter - other_id can never pass on OnDestroyed, and any Entity filter fails on OnStart/OnUpdate. Constrain only the fields the event actually provides.
  • Filters gate; they do not bind. The matched ids are never passed to the actions. An action cannot say "spawn at whatever entered" - it acts on its own configured target id. Use the filter to decide WHETHER the handler runs, then address entities by their known scenario ids.

Timer

Match the key carried by OnTimerEnd. It fails closed on every other event because those events carry no timer key.

Timer((key: "orbit_hold"))
Show explanation

Timer keys are scenario-local strings. This filter observes the event that already ended; it does not test whether a timer is currently running.

Cinematic

Match the cinematic key carried by OnCinematicFinished and OnCinematicSkipped. It fails closed on every other event because those events carry no scene key.

Cinematic((key: "strike_approach"))
Show explanation
field type default matches
key string required the scene's authored key, exactly

Scene keys are scenario-local strings. A scenario that plays two scenes and leaves this filter off answers whichever one ends first, so put it on every cinematic handler. A key no Cinematic action in the scenario plays is a lint Error, and so is an empty key: both refuse the scenario at load rather than silently dropping the handler.

ShipOrder

Match one helm order outcome, carried by any of the five ship order events. Every field is optional; every SET field must match exactly. It fails closed on every other event, which carries no order.

// the order this beat is waiting for
ShipOrder((order: Some("close_the_gap")))

// any order the warship finishes
ShipOrder((ship: Some("warship")))

// any alignment, by any ordered ship
ShipOrder((kind: Some(Align)))
Show explanation
field type default matches
order Option string None the key the helm action named the order
ship Option string None the ordered ship's id
kind Option kind None Move / Align / Stop / Patrol / Orbit (bare enum, no quotes)

The same filter reads all five outcomes - completion, interruption, resume, cancellation, failure - because the EVENT decides which outcome a handler listens for and the filter only qualifies it. Waiting on a completion and cleaning up on a failure is two handlers with the same filter.

ShipOrder(()), with nothing set, matches EVERY order outcome - a legitimate way to hang one handler off a whole scripted sequence, and neither an error nor a warning.

Order keys are minted by the action that installs the order, so the lint checks them against the MoveShipTo, ForceAlign, StopShip, PatrolShip and OrbitShip actions the scenario authors: a filter waiting on a key nothing ever issues is an Error, because that handler could never run.

Expression

Evaluate a variable condition; pass when it is true. The single tuple field is a condition node from the expression grammar - LessThan, GreaterThan or Equal over value expressions.

Expression((GreaterThan(
    Term(Factor(Name("asteroids_destroyed"))),
    Term(Factor(Literal(Number(4.0)))),
)))
Show explanation

An Expression filter reads ONLY the variable store - it ignores the event payload entirely, which is why it composes with any event, including OnUpdate.

Conditional

Boolean combinators over other filters (any kinds, nestable), written positionally: Not(<filter>), Or(<filter>, <filter>), And(<filter>, <filter>).

// not the player
Conditional(Not(Entity((id: Some("player_spaceship")))))

// either picket still standing
Conditional(Or(
    Expression((Equal(
        Term(Factor(Name("picket_a_down"))),
        Term(Factor(Literal(Number(0.0)))),
    ))),
    Expression((Equal(
        Term(Factor(Name("picket_b_down"))),
        Term(Factor(Literal(Number(0.0)))),
    ))),
))
Show explanation
variant arity passes when
Not(<filter>) 1 the inner filter does NOT pass
Or(<filter>, <filter>) 2 either passes
And(<filter>, <filter>) 2 both pass

The filters LIST is already an AND, so a top-level Conditional(And(..)) is redundant - And exists for composing under Or and Not. Nesting depth is bounded by RON's recursion limit (128), far past anything a sane script needs.

Traps for the unwary

Three rules catch every new author: filters fail closed, a payload that fails to serialize reads as no-match, and repeating events re-run ungated actions.

Show explanation
  • Filters fail CLOSED. Any evaluation error - an UNDEFINED variable, a type mismatch, a division by zero - logs an error and the filter returns false. The handler silently never fires. Consequence: seed EVERY variable your filters read in OnStart (variables a watch maintains are exempt). A missing seed is a soft-lock, not a crash - and the content lint warns on reads of never-set, unwatched variables.
  • A payload that fails to serialize also fails closed (loudly, in the log): every Entity filter reads "no match" for that event.
  • Repeating events + an ungated filter = repeated actions. OnUpdate fires every frame, and the lifecycle edges re-fire on every genuine transition (a lock landing again, orbit stability recovered). When the beat happens one time, mark the handler once: true and it retires the first time its filters pass. When the beat repeats, re-arm it in its own actions - the recipes.