Create / Modding reference / Actions

Actions

Everything a handler can DO. Actions run in authored order once every filter passes; each is a newtype variant - Name((field: value, ...)), double parens even for one field. Failures warn and continue (a missing target id never panics a scenario). All 52 at a glance:

action group what it does
SpawnScenarioObject world spawn one object: anchor, asteroid, planet, ship, beacon, or light
ScatterObjects world spawn count copies of a template at deterministic random positions
DespawnScenarioObject world remove a scoped object and its whole child hierarchy
CreateScenarioArea world spawn an invisible spherical sensor zone for OnEnter / OnExit
Objective mission post (or update in place) a HUD objective
ObjectiveComplete mission complete and remove the HUD objective with an id
ObjectiveMarkerAttach mission pin the gold HUD marker chip on a scoped object
ObjectiveMarkerDetach mission remove that marker
NarrativeCue mission speak one line into the comms stack
HudReadout mission bind a live HUD readout to a scenario variable
HintEmphasisSet mission pulse one keybind-dock chip gold
HintEmphasisClear mission drop the gold emphasis on one chip
PlaySound sound play one authored cue, heard in the cockpit
Sequence pacing run an ordered list of beats, each behind its own delay or gate
Cinematic pacing run the same chain as a SCENE the player is allowed to leave
CancelCinematic pacing end a running scene from the scenario rather than the player
CinematicTitle pacing post the shot's title card: where, when, and one line about it
Outcome flow show the VICTORY / DEFEAT banner and freeze the sim behind it
NextScenario flow queue a switch to another scenario by id
BackdropDone flow report a menu backdrop's act finished; the menu picks what plays next
SetShipCapabilityStop ship state grant or withhold STOP on a ship
SetShipCapabilityGoto ship state grant or withhold GOTO on a ship
SetShipCapabilityOrbit ship state grant or withhold ORBIT on a ship
SetShipCapabilityLock ship state grant or withhold the radar LOCK on a ship
SetShipCapabilityRcs ship state grant or withhold RCS on a ship
SetShipCapabilityPointDefense ship state let a ship's turrets answer incoming ordnance, or stand them down
SetAllegiance ship state overwrite a ship's side at runtime
MoveShipTo ship state fly an ordered ship to a mark and report when it arrives
ForceAlign ship state turn an ordered ship's nose onto a point and hold it there
StopShip ship state brake an ordered ship with the real STOP burn
PatrolShip ship state fly one loop of an authored waypoint route, back to where it started
OrbitShip ship state put a ship in a stable ring around a gravity well and hold it
ClearShipOrder ship state cancel whatever helm order a ship is under
ZeroShipMotion ship state take a ship's velocity away behind a cut, the player's included
ForceRailgunFire ship state fire one named railgun section
ForceTorpedoFire ship state launch one named torpedo bay at a named target
SetInfiniteAmmo ship state suspend or restore the finite magazine on every weapon of a ship
RefillAmmo ship state top a ship's magazines back up, or just one section's
SetAILeash ship state tether an AI ship's combat to a centre and radius, or release it
SetAIEngageRange ship state change how far an AI ship looks for hostiles, or restore the default
SetAIPointDefenseRange ship state change how close an inbound torpedo gets before the guns answer
VariableSet variables evaluate an expression and store the result in a variable
TimerStart variables start (or restart) a keyed scenario timer
TimerCancel variables cancel a running timer
DebugMessage variables log a line in debug builds
SetCamera camera pin the scenario camera at a pose
SetCameraAnchor camera ride the camera on an object, framing what you name
ReleaseCamera camera hand the camera back to the player's chase rig
SuspendPlayerControl camera block human gameplay input and clear held intent
ResumePlayerControl camera restore human gameplay input
Screenshot camera capture the primary window to a PNG
SetSkybox camera swap the scenario's skybox mid-scenario

Scoped targets. Every by-id action resolves its id ONLY among scenario-scoped entities (things this scenario spawned); the ship actions additionally require the id to be a ship ROOT. This is deliberate: ship SECTIONS carry per-ship ids like "controller" too, and an unscoped lookup would hit every ship's section. Referencing an id before it is spawned warns and does nothing - spawn first.

Spawning & the world

SpawnScenarioObject

Spawn one object. base is the shared identity block; kind picks the object and carries its config - the seven kinds are the Scenario objects reference.

SpawnScenarioObject((
    base: (id: "rock_1", name: "Rock", position: (100.0, 0.0, -400.0), rotation: (0.0, 0.0, 0.0, 1.0)),
    kind: Asteroid((radius: 50.0, texture: "dep://base/textures/asteroid.png", kind: "rock", initial_velocity: (0.0, 0.0, 0.0))),
)),
Show explanation
field type default meaning
base object base required identity + pose (below)
kind object kind required Anchor((..)) / Asteroid((..)) / Planet((..)) / Spaceship((..)) / Beacon((..)) / Light(..)

The base block:

field type default meaning
id string required the object's scenario id - the address every event, filter and by-id action uses
name string required display name
position 3-tuple required world position, meters
rotation 4-tuple required world rotation quaternion (x, y, z, w)

The same spawn id twice in ONE handler is a lint Error; the same id across two handlers (an either-or fork) is a Warn.

ScatterObjects

Spawn count copies of a template at deterministic random positions - the declarative asteroid-field primitive. Copy i gets id "{id_prefix}{i}".

ScatterObjects((
    id_prefix: "asteroid_",
    count: 20,
    seed: 433757350076153856,
    region: Box(min: (-1000.0, -200.0, -1000.0), max: (1000.0, 200.0, 1000.0)),
    template: (
        base: (id: "asteroid_", name: "Asteroid", position: (0.0, 0.0, 0.0), rotation: (0.0, 0.0, 0.0, 1.0)),
        kind: Asteroid((radius: 10.0, texture: "dep://base/textures/asteroid.png", kind: "rock", initial_velocity: (0.0, 0.0, 0.0))),
    ),
    asteroid_radius: Some((10.0, 30.0)),
    asteroid_kinds: [("rock", 12), ("carbon", 4), ("ice", 3), ("metal", 1)],
    min_separation: Some(320.0),
)),
Show explanation
field type default meaning
id_prefix string required id prefix for the copies (a filter id starting with this prefix lints clean)
count integer required copies; runtime cap 4096, absurd counts are a lint Error
seed integer required RNG seed - the same seed gives the same layout every load. Asteroid templates without an authored seed also get deterministic per-rock silhouette seeds derived from it, so the field's shapes are stable too
region region required sampling volume in meters (below)
template object config required the object each copy clones (any kind; same base/kind shape as SpawnScenarioObject)
asteroid_radius Option (lo, hi) None Asteroid templates only: randomize each rock's radius, in meters, in [lo, hi)
asteroid_kinds list of (kind, weight) required on an asteroid template the field's KINDS and how common each one is. Weights are relative COUNTS, not percentages: [("rock", 12), ("metal", 1)] is one metal body in thirteen. Empty on any other template
min_separation Option number None an EXTRA centre-to-centre floor in meters, on top of the automatic body-to-body clearance below. Measured against every body scattered so far this scenario, earlier scatters included; 64 placement tries per copy, unplaceable copies are DROPPED, never overlapped

region variants (struct variants - single parens, named fields):

variant fields meaning
Box(min: (..), max: (..)) both required uniform per axis in [min, max]
Ring(center: (..), inner: .., outer: .., y_min: .., y_max: ..) all required horizontal annulus: uniform angle, radius in [inner, outer], y in [y_min, y_max]

asteroid_kinds is drawn from the scatter's OWN seed, on a stream of its own: adding a kind to a field that already ships moves no rock and changes no radius. An asteroid template with no mix, or with every weight at zero, is a lint error and spawns nothing - a belt has to say what it is made of.

Bodies are kept clear of each other WITHOUT you sizing anything. Every placement records its own reach - a rock's noise mesh reach (up to 6x the nominal radius, drawn from the same seed the body will use), a planet's surface radius, zero for anything with no body - and a candidate is rejected inside existing reach + candidate reach. A field of mixed sizes is therefore packed by the bodies it actually holds, not by one number sized for its biggest pair. This checks earlier ScatterObjects placements only: a planet spawned by SpawnScenarioObject is not in that set. Keep the entire field outside each planet's sphere of influence plus the largest rock's physical reach if its template starts at zero velocity. Otherwise a rock can spawn inside the planet or start falling toward it; lint cannot infer a safe orbit from one velocity shared by every randomly positioned copy.

min_separation is what you add ON TOP of that: a spacing you want for gameplay rather than for geometry - a belt you can fly through, a picket line that reads as a line. The rule is max(min_separation, reach + reach), so a value smaller than two bodies side by side changes nothing.

A randomized asteroid_radius is drawn BEFORE placement, on a stream of its own, so a rock is the same size however many tries its neighbourhood cost. Scatter results are gameplay content and are never thinned by graphics quality.

DespawnScenarioObject

Remove the scoped object whose id matches (recursively, whole child hierarchy). The classic pairing is a beacon area the player has reached.

DespawnScenarioObject((id: "beacon_1")),
Show explanation
field type default meaning
id string required scoped object to despawn; missing id warns

A body despawned while inside a trigger area fires NO OnExit for itself.

CreateScenarioArea

Spawn an invisible spherical SENSOR zone that drives OnEnter / OnExit under its id.

CreateScenarioArea((id: "safe_zone", name: "Safe Zone",
    position: (0.0, 0.0, -1000.0), rotation: (0.0, 0.0, 0.0, 1.0), radius: 100.0)),
Show explanation
field type default meaning
id string required the id OnEnter/OnExit report as the area
name string required display name
position 3-tuple required sphere centre, meters
rotation 4-tuple required rotation (cosmetic for a sphere)
radius number required sensor radius, meters

Works mid-scenario, and works even when created AROUND a body already inside (the entry still fires).

A beacon with area_radius is its OWN area - no separate CreateScenarioArea needed for it.

Mission & story

Objective

Post a HUD objective. Objectives state goals; comms lines (NarrativeCue) carry voice.

Objective((id: "destroy_asteroids", message: "Objective: Destroy 5 asteroids!")),
Show explanation
field type default meaning
id string required opaque handle ObjectiveComplete uses
message string required the objective text on the HUD

Re-posting the same id with new text updates the entry in place (the "recovered N/3" tally trick). A duplicate post otherwise warns.

ObjectiveComplete

Complete (remove) the HUD objective with this id.

ObjectiveComplete((id: "destroy_asteroids")),
Show explanation
field type default meaning
id string required the objective to complete; a never-posted id warns (and lints)

ObjectiveMarkerAttach

Pin the gold HUD marker chip (label + live distance) on a scoped object.

ObjectiveMarkerAttach((target_id: "beacon_1", label: "BEACON 1")),
Show explanation
field type default meaning
target_id string required scoped object to mark; spawn it first
label string required short chip text ("BEACON 1")

Re-attaching updates the label in place. A despawned target detaches implicitly.

ObjectiveMarkerDetach

Remove that marker.

ObjectiveMarkerDetach((target_id: "beacon_1")),
Show explanation
field type default meaning
target_id string required scoped object to strip; a missing id is quietly fine (detach-after-despawn is legitimate)

NarrativeCue

Speak one line into the HUD comms stack (bottom-left, arrival order, ~8 s hold each, at most three visible). Pending lines wait without being dropped. One line per beat is still the style; the queue is the safety net.

NarrativeCue((speaker: "Foreman Okono", text: "Strip it clean, Kestrel.", dwell: Some(12.0))),
NarrativeCue((speaker: "Copilot", text: "You heard the man.")),
NarrativeCue((
    speaker: "Unknown Signal",
    text: "- state registry and -",
    accent: LinearRgba((red: 1.0, green: 0.55, blue: 0.2, alpha: 1.0)),
)),
Show explanation
field type default meaning
speaker string required the distinct uppercase speaker header
text string required the line
dwell Option number None per-line hold override in seconds, clamped to [3, 30] (lint warns outside); Some(12.0)
icon Option asset ref None speaker portrait (Some("self://icons/okono.png")); omitted = the cockpit fallback tile
accent color the comms blue the one colour the card is drawn in; see below

The accent. A line carries ONE presentation fact, and it is a colour, not an id. The panel derives everything it paints from it: the border, the speaker header, the fallback icon tile, and the reading copy lifted toward white. The background is the same dark for every card, and a card's opacity depends only on its normal display and fade.

Leave it out and the line is drawn in the comms blue - the work channel, traffic addressed to this ship, which is what nearly every line in a scenario is. Authoring the blue on all of them would bury the few that differ, so the default is written nowhere and the generated base scenarios carry no accent: line at all.

Name a colour when the line is NOT ordinary traffic: a distress band, a corporate net, a voice in the room off the radio, something the cockpit merely overheard. Nothing looks an id up, so nothing can fail to resolve one, and a mod invents as many voices as it has colours. Colour is tagged the same way it is everywhere else in content - LinearRgba((..)) or Srgba((..)).

Two lines the player must tell apart should differ by more than a shade. The useful distance is being CALLED versus OVERHEARING, and blue against amber reads at a glance where two blues do not.

Pacing is yours, not the panel's. A card holds its full time whatever is waiting behind it - the panel never takes reading time back to catch up - so the gap between two POSTS is what sets the reading rate. Post a line every 3-4 seconds and the reader has three cards up, the newest shoving the others upward as it lands; six to seven seconds leaves the card being read as the only one moving. Budget more where the player is flying and less where a cut has taken the helm, and keep a line to one thought: a card that needs longer than its hold is two lines, not one line held open.

Scenario-scoped: teardown clears the log.

Two story lines in one handler is a lint Warn (unreadable); a story line beside an Outcome in one handler is a Warn (frozen behind the overlay). Let the outcome's own message carry the closing line.

HudReadout

Bind a live HUD readout to a scenario variable - the DISPLAY half of the variable vocabulary (a run clock, a score, a countdown).

HudReadout((slot: "run_timer", variable: "scenario_elapsed", format: Time, label: Some("TIME"))),
Show explanation
field type default meaning
slot string required stable readout id; several slots run side by side; fire again to update or clear
variable string required the variable shown (e.g. "scenario_elapsed"); undefined or non-numeric reads 0.0
format format Number Number (one decimal), Integer (rounded), Time (mm:ss.s)
label Option string None caption before the value (Some("TIME"))
visible bool true true shows/updates; false clears exactly this slot

One fire is enough; the readout tracks the variable's current value every frame thereafter. It freezes under pause and behind the outcome overlay because the variable does - a time-trial's final time simply holds through the banner. Cleared at teardown.

HintEmphasisSet

Pulse one keybind-dock chip gold - how a tutorial points at a key before granting it.

HintEmphasisSet((verb: "RADAR")),
Show explanation
field type default meaning
verb string required one of "STOP", "GOTO", "ORBIT", "CANCEL", "RADAR", "COMPONENT", "RCS", "DOCK", "HELM"; unknown verbs warn and do nothing

The dock normally hides verbs the player cannot use yet, so emphasizing an unavailable verb REVEALS its chip dimmed and pulses it. Emphasis never grants the capability (the SetShipCapability* actions do).

HintEmphasisClear

Drop the gold emphasis on one chip (teardown clears all emphasis anyway).

HintEmphasisClear((verb: "RADAR")),
Show explanation
field type default meaning
verb string required the chip to un-pulse

Sound

PlaySound

Play one authored one-shot. Sound that BELONGS to something in the world - an intake's take ding, a hull coming apart - is authored on that object and played where it happens. This is the other kind: the cue a scene needs and nothing in the world produces.

PlaySound((sound: "self://sounds/warn_lock.wav", route: Interface)),
PlaySound((sound: "self://sounds/warn_hull.wav", route: Hull, volume: Some(0.4))),
Show explanation
field type default meaning
sound asset ref required the clip, resolved like any other content sound ref
route Interface | Hull required where it is heard
volume Option number None gain; omitted is full volume, Some(0.4) sits a cue UNDER a comms line

Interface is UI chrome - a prompt, a stinger, a chapter tone - scaled by the interface track. Hull is structure-borne through the player's own ship, something they feel rather than hear, scaled by the world track. The route is authored because those are different cues and neither is the safe guess.

Non-positional on purpose. An exterior cue is attenuated and panned by where it happened, and a beat has no position to give one; a cue authored here is heard in the cockpit, which is where the player is. Author the positional kind on the object that makes it.

Pacing

Sequence

Run an ordered list of BEATS. The ENGINE holds the cursor, so a paced chain costs one action and no scenario variable.

Sequence((
    key: "opening",
    steps: [
        (
            after: Some(2.0),
            actions: [
                NarrativeCue((
                    speaker: "Capt. Halloran",
                    text: "Kestrel, you are cleared to burn.",
                )),
            ],
        ),
        (
            after: Some(8.4),
            actions: [
                Objective((id: "b1_burn", message: "Fly to BEACON 1.")),
            ],
        ),
    ],
)),
Show explanation
field type default meaning
key string required scenario-local chain key; the engine files the cursor under it
steps list required the beats, in order; at least one

A step:

field type default meaning
after seconds none scenario time to wait, from when this step became current
until event + filters none an event that must arrive, qualified by its filters
deadline seconds none how long the step may wait before the chain is called stuck; REQUIRED with until
actions list [] what the beat does

Both waits may sit on one step, and they run TOGETHER: a gate that opens early still owes the delay, and a delay that elapses first still owes the gate. A step with neither runs the moment it becomes current - which is how a hand-off beat rides the end of a chain.

(
    after: Some(6.0),
    until: Some((
        name: OnUpdate,
        filters: [Expression((Equal(
            Term(Factor(Name("surveyed"))),
            Term(Factor(Literal(Number(1.0)))),
        )))],
    )),
    deadline: Some(600.0),
    actions: [ /* the beat */ ],
),

The semantics are WAIT, never SKIP: a step whose gate stays shut blocks the beats behind it. That is why a gated step must carry a deadline - when it expires the chain STOPS and logs an error, so a soft-lock is loud instead of silent. content lint refuses a gated step without one.

Delays ride the pause-frozen scenario clock, so a gap measures play time, not wall time.

One cursor per key. Starting a key whose chain is still running is refused and logged; a chain that has finished frees its key. Several handlers MAY start the same key when only one of them can ever fire - every win variant of a scenario starting one shared outro chain is the idiom.

A step's actions are a FRAME of their own, landing seconds after the handler that queued them, and everything else applies inside a step unchanged: an objective still must not share a beat with a comms line, and a beat may start a chain of its own.

What it replaces. A paced chain used to be sibling OnUpdate handlers strung together by hand: a counter variable seeded in OnStart, a VariableSet per beat to advance it, and a filter per beat reading both the counter and scenario_elapsed against a stamped deadline. All of that is about the machine. Reach for a chain whenever the only thing a beat is waiting for is "later"; keep a handler where the beat must still ASK something when it lands - which of two lines to speak, whether the fight is still live - because a step runs when its wait ends and only a handler can re-check.

Cinematic

Run the same beat chain as a SCENE - one the player is allowed to walk out of. Mechanically a Sequence with a different contract: a sequence is scenario logic and always runs to its end; a scene takes the camera and the controls away, and a player who has seen it once must be able to get them back.

Cinematic((
    key: "strike_approach",
    skippable: true,
    steps: [
        (
            after: Some(0.0),
            actions: [
                SuspendPlayerControl(()),
                SetCameraAnchor((anchor: "cutter", offset: (440.0, 25.0, -235.0), frame: World, look_at: Object("warship"))),
            ],
        ),
        (
            after: Some(14.0),
            actions: [
                NarrativeCue((speaker: "Unknown Signal", text: "- vessel this net -", accent: LinearRgba((red: 1.0, green: 0.55, blue: 0.2, alpha: 1.0)))),
            ],
        ),
    ],
)),
Show explanation
field type default meaning
key string required scenario-local scene key; the cursor is filed under it and both cinematic events report it
skippable bool required whether the player may leave early
steps list required the beats, in order - the same after / until / deadline / actions a Sequence step has

The two ends are EVENTS, not fields. A scene does not carry an on-finish action list; it REPORTS, and the handlers that answer live beside it:

  • OnCinematicFinished fires on every path out - the last beat, a skip, a CancelCinematic, a blown step deadline. What the scene owes the player back (camera released, control returned, the objective it was covering) is authored there, ONCE, so no path can forget it.
  • OnCinematicSkipped fires first on a skip, and carries only the catch-up: what the beats that never played would have left behind - the actors spawned, moved or destroyed, the variables latched.

Filter both with Cinematic((key: "...")), or a scenario with two scenes answers the wrong one. A filter naming a key no Cinematic plays is a lint Error, and a lint error refuses the scenario at load.

skippable is authored on every scene because a scene that cannot be left is a real decision - three seconds long, or the first time only - and not the kind of thing that should happen because a field was left out.

skippable: true puts a prompt on the screen. The HUD prints <KEY> SKIP SCENE bottom-centre, above the keybind dock, for as long as the scene will take the key; it reads the player's live binding for the cinematic_skip action, so a rebind is reflected mid-scene and a pad player is shown a pad glyph. It appears when the 0.75 s hold-off below expires, not when the scene opens, and it goes the moment the scene ends by any path. There is no field to suppress it: the prompt IS the promise the flag makes, and it is one of the two HUD surfaces the player's own hide-HUD toggle cannot take away.

A skip cancels the CURSOR. It never runs the remaining steps at speed: a step chain is not a list of world edits, it holds camera moves, comms lines and shots the player has just said they do not want. Whatever the world still needs belongs in the skipped handler.

The skip binding is held off for the first 0.75 s of the scene. The player is very often still holding the button that got them here, and without the hold-off a scene ends on the frame it opens and reads as a bug.

Nested chains outlive the skip. A Sequence started inside a scene has its own cursor, and cancelling the scene does not cancel it. A line that must not survive a skip belongs in the scene's own steps, not in a chain the scene started.

CancelCinematic

End a running scene now, from the scenario rather than from the player.

CancelCinematic((key: "strike_approach")),
Show explanation
field type default meaning
key string required the scene to end; a key no Cinematic plays is a lint Warn

The scene still reports OnCinematicFinished, so whatever it took is given back. It does NOT report a skip: nobody asked to leave. Cancelling a key that is not running is a quiet no-op.

CinematicTitle

The shot's title card: where this is, when it is, and one line about it. Fades in top-left, holds, fades out.

CinematicTitle((
    corner: TopLeft,
    location: "SECTOR M, THE RINGS OF SATURN",
    date: "Y0",
    note: "The EWI carrier Meridian: a refinery that moves to the ice.",
    seconds: 9.0,
)),
Show explanation
field type default meaning
corner TopLeft | TopRight | BottomLeft | BottomRight required which corner the card sits in
location string required where this is, drawn largest
date string required when this is - a year, a date, a time of shift
note string required one line of context; empty draws no line
seconds float required how long the card holds, fades included

Nothing takes the card down. It expires on its own clock, so a scene that is skipped, cancelled, deadlined or torn down leaves a card that simply finishes. There is no handler to write and no ClearCinematicTitle to forget.

One card at a time. Posting a second replaces the first at its own age - two cards on screen are two answers to "where am I".

The corner is authored because only the shot knows which one is free. BottomLeft is the comms stack's corner, so a scene with dialogue in it wants a top corner; the top strip carries the status bar, which the card clears. A right-hand card mirrors a left-hand one: the accent rule stays on the outer edge and the lines stack against it.

The clock is the scenario's, not the frame's. A card holds through a pause rather than bleeding away behind the menu. A seconds shorter than the two fades together peaks lower instead of snapping on and off, which is the honest reading of "show this for half a second".

Flow: outcomes & transitions

Outcome

Declare the scenario's win or lose: the gold VICTORY / red DEFEAT banner, an optional message, and buttons - and it freezes the simulation behind it exactly like the pause menu until it clears.

Outcome((outcome: Defeat, message: Some("The cutter is lost."))),
NextScenario((scenario_id: "the_wreck", linger: true)),
Show explanation
field type default meaning
outcome kind required Victory or Defeat
message Option string None the line under the banner; strict RON Some("...")
auto_advance_secs Option number None timed banner: advance the queued LINGERING switch after this many REAL seconds (the player can still click sooner); cap 300 s; meaningless without a lingering switch queued

PRESENTATION ONLY: compose the consequence beside it. Queue a lingering NextScenario and the overlay offers Continue (Victory) or Retry (Defeat); queue nothing and it offers only Main Menu.

The paired switch must LINGER: an instant switch tears the scenario down the same frame and SWALLOWS the banner (lint warns on that composition).

NextScenario

Queue a switch to another scenario by id - a hard cut, a delayed cut, or a modal hold behind the outcome overlay.

NextScenario((scenario_id: "the_wreck", linger: true)),
Show explanation

Three gears:

  • Hard cut - linger: false, no delay: switches on the next sync.
  • Delayed cut - linger: false, delay: Some(4.0): the world keeps playing for the delay (a story line can land), then cuts. Ticks on pause-frozen time; Enter skips the wait.
  • Modal hold - linger: true: waits for the scenario-advance input (Enter / DPadDown) or the outcome overlay's Continue/Retry.
field type default meaning
scenario_id string required target scenario; a dangling id is a lint Error
linger bool required true defers until released; false switches now (or after delay)
delay Option number None delayed non-lingering cut, seconds; cap 300 s, lint warns above 60; meaningless with linger: true

The last request wins wholesale - a fresh NextScenario replaces a queued one and resets its clock.

A menu backdrop may not author NextScenario (lint Error): it reports with BackdropDone instead.

BackdropDone

Report that a menu backdrop's act is finished. The menu holds the shot for one second, then cuts to the next backdrop in its rotation.

BackdropDone(()),
Show explanation

No fields. The backdrop never names its successor: the menu plays every backdrop once, in a shuffled order, before any plays again, and never the same one twice in a row when it has two or more.

  • Every role: Backdrop scenario must author one (lint Error otherwise). A scene with no natural ending carries a timer, like the base backdrops' 150 s rotation limit.
  • BackdropDone anywhere else is a lint Error: nothing reads it.
  • The one-second beat runs on pause-frozen time. A second report inside the beat changes nothing.

Ship state

Ship capabilities

Grant or withhold one capability on a scoped SHIP - the tutorial-progression primitive (Basic Training starts with everything but the guns withheld and grants one per beat, GOTO after the cadet's first lock).

One action per capability: SetShipCapabilityStop, SetShipCapabilityGoto, SetShipCapabilityOrbit, SetShipCapabilityLock, SetShipCapabilityRcs, SetShipCapabilityPointDefense. All six take the same two fields. The ship's seventh capability, dock_enabled, is authored at spawn and has no action.

SetShipCapabilityGoto((id: "player_spaceship", enabled: true)),
// The battery answers a salvo by itself - until this takes it away.
SetShipCapabilityPointDefense((id: "player_spaceship", enabled: false)),
Show explanation
field type default meaning
id string required scoped ship root
enabled bool required true grants, false withholds

The capability lands on the ship ROOT, not on its flight computer: a hull that loses every controller section loses its steering, never its permissions. Never withhold STOP by convention - an engaged autopilot should always be cancelable.

The spawn-time twin is the capabilities block on the spaceship object; these actions are its runtime mirror.

SetAllegiance

Overwrite a scoped ship's side at runtime - the neutral-until-provoked primitive: spawn a bystander Neutral, flip it Enemy on a trigger.

SetAllegiance((id: "magpie", allegiance: Enemy)),
Show explanation
field type default meaning
id string required scoped ship root; a dangling id is a lint Error
allegiance side required Player / Enemy / Neutral (bare enum)

Helm orders

The six helm actions below take the helm of a ship the scenario owns: one authored with controller: None OR one authored with controller: AI (see Spaceship). They refuse the PLAYER's ship - a lint Error, and a runtime error if one somehow reaches the engine - because a player's flight input drops any autopilot on the next frame, so the order would fight the stick and lose.

An order on an AI ship OUTRANKS the bot. While the order runs, the AI stops writing to the helm; it keeps looking around and keeps shooting. This is the seam that lets a mission tell an ordinary patrol craft to go somewhere specific and then hand it back, instead of authoring a second inert hull.

The ship still needs to be a real ship: the helm orders fly it with its own flight computer and thrusters, so a hull with no live controller section cannot turn, and a battery with no drives can still shoot but never move.

Move, Align, Stop, Patrol and Orbit are ONE mutually exclusive family. A ship holds at most one helm order; installing a second cancels the first. Each is keyed, and the order events with a ShipOrder filter are how the next beat waits for it

  • which is what lets a Sequence until gate hold a set piece together.

Some orders end and let go; others end and KEEP HOLDING. Move, Stop and Patrol release the helm when they report, so an AI ship goes straight back to its own routine. Align and Orbit report the condition and then hold it until something else takes the helm - the hold is the point of both.

MoveShipTo

Fly an ordered ship to a mark and report when it gets there. The ship's own GOTO maneuver, so it accelerates, coasts and arrives on its authored thrust - this is the cinematic approach, not a teleport.

MoveShipTo((
    order: "close_the_gap",
    ship: "warship",
    position: (0.0, 0.0, -1200.0),
    arrival_standoff: Some(60.0),
)),
Show explanation
field type default meaning
order string required the key this order's completion is reported under; an empty key is a lint Error
ship string required scoped None- or AI-controller ship root; a dangling or player-driven id is a lint Error
position meters (x, y, z) required the mark to fly to, in world coordinates
arrival_standoff Option meters None the margin to leave between the ship's own hull and the mark; Some(0.0) parks the hull's face on it

Completes when the ship's autopilot lets go - it is inside the standoff and settled - and fires OnShipOrderComplete with kind: Move.

arrival_standoff exists because the default 500 m is far too coarse to stage a shot with. It is a MARGIN, not a centre distance: the ship comes to rest with that much clear water between its own hull and the mark, so a warship and a shuttle sent to the same mark both stop where you staged them. It is installed for the life of the order and taken back off when the order retires - on completion as well as on a cancel or an interruption - so a cinematic's tight staging does not silently retune every later GOTO the hull flies. None uses the ship's own standoff.

A margin is a PARKING rule, not a route guarantee. The autopilot plans no route and dodges nothing on the way in, and gravity can bend the leg; stage the approach yourself if something is in it.

ForceAlign

Turn an ordered ship's whole hull onto a point and HOLD it there. Rotation only - no autopilot, so no drive ever burns for translation and the ship keeps whatever velocity it had.

ForceAlign((
    order: "bore_on_target",
    ship: "warship",
    look_at: (0.0, 0.0, 0.0),
    tolerance_degrees: 1.5,
)),
Show explanation
field type default meaning
order string required the key this order's completion is reported under
ship string required scoped None- or AI-controller ship root
look_at meters (x, y, z) required the world position to put under the bore
tolerance_degrees number required how close the aim must come before the order completes; must be finite and not negative, or the order is refused

The hold is the point. A railgun does not traverse, so the shot leaves down whatever line the hull is holding: align, wait for the completion, then fire, and the slug goes where you aimed. The facing is held until another helm order replaces it, which is what lets several guns run their charges on one bearing.

The tolerance is also what "settled" is measured against - a tight tolerance asks for a genuinely steady hull, a coarse one accepts a drift - so an alignment order on a tumbling wreck may take a while, and one with an impossible tolerance is refused outright rather than left never completing.

StopShip

Brake an ordered ship with the real STOP maneuver - the same flip-retrograde-and-burn the player's X key runs, so it costs fuel and time, visibly.

StopShip((order: "hold_here", ship: "warship")),
Show explanation
field type default meaning
order string required the key this order's completion is reported under
ship string required scoped None- or AI-controller ship root

Completes when the maneuver ends, with kind: Stop. "Ended" is a deadband and not a dead stop: a residual the drive is already pointing at is braked out to 2 m/s, and one it is not - a sideways drift, or a head-on crumb - is released at up to 7.5 m/s rather than chased with attitude flips. A hull granted RCS settles the remainder on its jets. So a ship already inside that band completes almost immediately, and a beat that needs the hull genuinely still should gate on a Speed query rather than on this order alone.

PatrolShip

Fly ONE loop of an authored waypoint route: the ship visits each mark in order, returns to the first one, and reports. This is the sweep-and-come-back beat - not a standing assignment.

PatrolShip((
    order: "sweep_the_belt",
    ship: "picket",
    waypoints: [
        (0.0, 0.0, -800.0),
        (900.0, 0.0, -800.0),
        (900.0, 0.0, 0.0),
    ],
)),
Show explanation
field type default meaning
order string required the key this order's completion is reported under
ship string required scoped None- or AI-controller ship root
waypoints list of meters (x, y, z) required the marks to visit, in order; an EMPTY list is a lint Error

ONE loop, then kind: Patrol and the helm goes back. A route of one point is one leg out and one leg home; a route with the same point twice in a row still flies both legs, and the second lands immediately. Repeat the action to run another loop, or hand the ship a ClearShipOrder mid-route to abandon it.

For a STANDING patrol that never ends, author the route on the ship instead - an AI ship's own patrol field (see Spaceship) is the routine it returns to when nothing is happening.

OrbitShip

Put a ship in a stable ring around a gravity well and hold it there. The ship's own ORBIT maneuver, so it aligns, burns into the band, and stays.

OrbitShip((order: "take_the_ring", ship: "surveyor", well: Authored("planetoid"))),
OrbitShip((order: "park", ship: "surveyor", well: NearestToShip)),
Show explanation
field type default meaning
order string required the key this order's completion is reported under
ship string required scoped None- or AI-controller ship root
well Authored(id) or NearestToShip required Authored names one gravity-well object this scenario spawned; a dangling id is a lint Error. NearestToShip takes, among loaded wells with a stable ring, the one whose gravity owns the ship when the order engages, else the one whose surface is nearest, streamed wells included. The ring can still cross a neighbouring world. An order resumed after an interruption picks again and can take another well

Completes with kind: Orbit the moment the ring is ESTABLISHED - the ship is in the band and holding - and then keeps holding it, the same way ForceAlign keeps its bearing. A completion here means "it is in orbit now", not "it has stopped orbiting".

No loaded well that matches, two wells that match equally, or a well with no stable band for this hull fails the order rather than leaving the beat waiting: see OnShipOrderFailed. Two wells at the same surface distance fall to the lower id.

If the well goes after the ring is established - a streamed sector unloads, the body is destroyed - the order retires without another event and the helm goes back to the ship. It does not pick the well up again if it reloads.

ClearShipOrder

Release a ship's helm. The counterpart to the five orders above: whatever the ship was told, it is no longer being told it.

ClearShipOrder((ship: "warship")),
Show explanation
field type default meaning
ship string required scoped ship root to release

A None-controller hull KEEPS its velocity and coasts - this is space, and letting a ship drift out of frame is usually the point. An AI ship goes back to its own routine on the next frame.

Emits OnShipOrderCanceled, NOT a completion: an order that was called off did not finish, so a beat gated on the completion correctly never runs. It also puts back the ship's own arrival standoff if a MoveShipTo had displaced it. Clearing a ship that is under no order does nothing and says nothing.

ZeroShipMotion

Take a ship's motion away outright: linear and angular velocity to zero, this frame, with no maneuver flown and no fuel spent.

NOT a helm order. It installs nothing, reports nothing, cancels nothing, and it is the one stop a PLAYER hull accepts.

ZeroShipMotion((id: "kaveri")),
Show explanation
field type default meaning
id string required scoped ship root, any controller

This is the CINEMATIC counterpart to StopShip, and the only one of the two a player's hull accepts. A helm order cannot share a helm with live input, so StopShip refuses a player ship out loud; but a scene that cuts the camera away for a minute of dialogue has to leave the player's ship somewhere sane, and asking a suspended pilot to coast on through a rock field is not it. Use it behind a cut, where the ship is off screen and the arithmetic is nobody's business - season one's distress call does exactly that, and is worth reading.

It only ever takes speed off. A ship whose controller still wants to burn is moving again on the next tick, so pair it with SuspendPlayerControl for a player hull or with ClearShipOrder for an ordered one. It reports no completion: there is no maneuver to finish.

Forced fire

The two weapon actions below are independent of the helm and of each other: a ship can be aligned, firing its spinal gun and launching a bay in the same frame.

Unlike the helm orders, these still refuse an AI-driven ship as well as the player's. A bot picks its own targets and times its own shots, and a scripted trigger pull cutting across that would fight the same weapon seams every frame.

ForceRailgunFire

Fire one named railgun section of a scripted ship.

ForceRailgunFire((ship: "warship", section: "spinal")),
Show explanation
field type default meaning
ship string required scoped None-controller ship root that fires
section string required the authored section id of the railgun; a section the hull does not carry, or one that is not a railgun, is a lint Error

No target and no steering: a railgun does not traverse, so the shot leaves down whatever line the hull holds when the charge completes. Putting that line on something is ForceAlign's job. Everything else is the gun's own behavior - the authored charge time, the magazine, the reload, the recoil through the hull, the slug, the sound and the flash.

The order is ONE SHOT: it holds the trigger until the gun actually fires (through a reload, if the magazine was empty) and then retires itself. Fire a second shell with a second action.

ForceTorpedoFire

Launch one named torpedo bay of a scripted ship at one named target.

ForceTorpedoFire((ship: "battery_west", section: "bay", target: "patrol_ship")),
Show explanation
field type default meaning
ship string required scoped None-controller ship root that launches
section string required the authored section id of the bay; a missing id or a non-bay is a lint Error
target string required scoped ship root the ordnance homes on; a dangling id is a lint Error

ONE bay, addressed by its section id. The bay's own cooldown and ammo still time the launch, and the ordnance is committed to the target the same way an AI or player launch is - which is also what makes the torpedo visible to hostile point defense. The AI's launch gates (range envelope, hull-forward cone, line of sight, the AI cadence) do NOT apply: the script is the decision.

A missing target skips the launch entirely, so no dud goes out while a target is mid-respawn. The order is one shot, like the railgun's.

SetInfiniteAmmo

Suspend (or restore) the finite magazine on every weapon of a scoped ship, so a training range or a set-piece is not decided by a reload.

SetInfiniteAmmo((id: "player_spaceship", enabled: true)),
SetInfiniteAmmo((id: "player_spaceship", enabled: false)),
Show explanation
field type default meaning
id string required scoped ship root; a dangling id is a lint Error
enabled bool required true suspends the magazines, false restores them

true puts each weapon's finite magazine aside and lets the section fire on its cooldown alone. false gives the magazine back at the section's own AUTHORED capacity, full, with the reload re-seeded from the section prototype - not at whatever count was left when it was suspended. A count from before the suspension is not a count the run earned either, and a full magazine is the one state a player and an author can both predict.

Turning it on twice is idempotent, and turning it off on a ship that never had it on does nothing. A weapon authored with no magazine at all was already unlimited and is untouched in both directions.

This is an authoring bypass, not the ordinary reload: the ordinary idle reload (see Turret reload) spends matching ammunition from the ship's own inventory; this action never touches it, in either direction.

RefillAmmo

Top a scoped ship's finite magazines back up without changing how they work.

RefillAmmo((id: "player_spaceship")),                          // every weapon
RefillAmmo((id: "player_spaceship", section: Some("turret_dorsal"))), // just one
Show explanation
field type default meaning
id string required scoped ship root; a dangling id is a lint Error
section Option string None one section's authored id, written Some("..."); omitted refills every weapon on the ship

Each magazine returns to its authored capacity and any reload in flight is cleared. Weapons with no finite magazine - and any whose magazine is currently suspended by SetInfiniteAmmo - are skipped.

An authoring bypass, not the ordinary reload: it fills the magazine directly and spends nothing from the ship's inventory, unlike the ordinary idle reload.

AI constraints

The three actions below retune an AI ship's JUDGEMENT at runtime: how far it will chase, how far it looks, how close it lets a torpedo come. They accept ONLY an AI-controller ship - a hull nobody is thinking for has nothing to retune, so a None or player id is a lint Error.

They are independent of each other and of the helm. Each takes ONE optional payload: set it to install or update the constraint, omit it to put the ship back on the engine default. They are also outranked by a helm order - while one runs, the AI is not flying, so a constraint change has no visible effect until the order lets go.

The spawn-time twins are the AI controller's own leash, engage_range and pd_range fields (see Spaceship); these are their runtime mirrors.

SetAILeash

Tether an AI ship's combat to a centre and a radius, or release it. Past the radius the ship breaks off and comes home.

SetAILeash((ship: "picket", leash: Some((center: (0.0, 0.0, -800.0), radius: 2000.0)))),
SetAILeash((ship: "picket")),   // let it chase freely
Show explanation
field type default meaning
ship string required scoped AI-controller ship root
leash Option leash None Some((center: ..., radius: ...)) installs or updates the tether; omitted RELEASES it
leash.center meters (x, y, z) required the anchor the radius is measured from, in world coordinates
leash.radius meters required how far from the anchor combat may go; must be positive

Widening a leash mid-scenario is how a garrison is let off its post; releasing it entirely turns a picket into a pursuer. The anchor is an authored point, not the ship - moving the ship does not move the tether.

SetAIEngageRange

Change how far an AI ship looks for hostiles, or restore the engine's 4 km default.

SetAIEngageRange((ship: "watchtower", range: Some(16000.0))),
SetAIEngageRange((ship: "watchtower")),   // back to the default
Show explanation
field type default meaning
ship string required scoped AI-controller ship root
range Option meters None Some(16000.0) installs or updates the range; omitted RESTORES the default; must be positive

Wide is a long-watch emplacement that wakes for targets parked outside everyone else's detection. Short is a ship that ignores a brawl next door. Narrowing it mid-fight does NOT drop a target it has already acquired.

SetAIPointDefenseRange

Change how close an inbound torpedo gets before an AI ship's guns answer it, or restore the engine's 1.5 km default.

SetAIPointDefenseRange((ship: "escort", range: Some(600.0))),
SetAIPointDefenseRange((ship: "escort")),   // back to the default
Show explanation
field type default meaning
ship string required scoped AI-controller ship root
range Option meters None Some(600.0) installs or updates the range; omitted RESTORES the default; must be positive

Short stages the intercept close in, where the player can see it. Past the turret's own ~1.8 km reach it only wastes the opening shots, so authoring wider than the guns can shoot buys nothing.

Variables, timers & debugging

TimerStart

Start a keyed scenario timer. Starting an existing key restarts its deadline.

TimerStart((
    key: "orbit_hold",
    seconds: Term(Factor(Literal(Number(8.0)))),
)),
Show explanation
field type default meaning
key string required scenario-local timer key
seconds numeric expression required live, unpaused seconds until OnTimerEnd

The duration is a numeric expression and must evaluate to a positive finite number. Invalid values log an error and leave an existing timer unchanged.

Timers freeze under pause and clear on retry or teardown. Use an OnTimerEnd handler with a Timer filter to react once.

A timer earns its handler when the beat must still ASK something when it lands - is the wing still alive, which line fits. A run of beats that only waits for later is a Sequence instead.

TimerCancel

Cancel a running timer. A missing key is a no-op.

TimerCancel((key: "orbit_hold")),

VariableSet

Evaluate an expression against the CURRENT variables and store the result - the write half of the whole variable vocabulary. Re-evaluated per event, so n = n + 1 accumulates.

VariableSet((
    key: "asteroids_destroyed",
    expression: Add(Factor(Name("asteroids_destroyed")), Term(Factor(Literal(Number(1.0))))),
)),
Show explanation
field type default meaning
key string required mutable variable to write (overwrites); writing a watched variable is a lint ERROR
expression expression node required see the expression grammar; an evaluation error skips the write

In the in-game editor the expression is not a text field: the action opens a Value page under its Key, a row per node, exactly as an expression filter opens its condition. See the typed form.

DebugMessage

Log a line (debug builds; run with --features dev while iterating). No game effect - sprinkle these to watch handlers fire.

DebugMessage((message: "gate 3 armed")),
Show explanation
field type default meaning
message string required the log line

Camera & photo mode

SetCamera

Pin the scenario camera at a pose (drops free-fly control; the pose is re-enforced every frame).

SetCamera((position: (0.0, 300.0, 800.0), look_at: (0.0, 0.0, 0.0))),
// The same pose as a MOVE rather than a cut.
SetCamera((position: (0.0, 300.0, 800.0), look_at: (0.0, 0.0, 0.0), blend: Some((seconds: 4.0)))),
Show explanation
field type default meaning
position 3-tuple required world camera position, meters
look_at 3-tuple required world point to face, meters (up is +Y)
blend Option blend None omitted is a CUT; Some((seconds: 4.0)) moves the camera there over four seconds

Blends. A blend rides on top of the pose, so a blend onto an anchored shot keeps tracking its subject all the way in. seconds is how long the move takes; easing is how those seconds are spent - omitted is Smooth, and Linear is the special case that wants to look like a rig on a rail (Some((seconds: 4.0, easing: Linear))). A pose authored as a cut also REMOVES any blend still running, or the cut would be swallowed by the move it was meant to interrupt.

Part of the screenshot/photo surface; no scenario camera present is a warn no-op.

SetCameraAnchor

Ride the camera on a scenario object at a fixed offset, facing what you name. The shot follows the object every frame, so a cinematic can hold the player's own ship in frame while they are still flying it.

// Over the player's shoulder, watching the carrier come apart.
SetCameraAnchor((anchor: "cutter", offset: (140.0, 55.0, -195.0), frame: World, look_at: Point((-1000.0, 0.0, 2500.0)))),
// Riding the hull, watching whatever is coming.
SetCameraAnchor((anchor: "cutter", offset: (-165.0, 30.0, 70.0), look_at: Object("warship"))),
Show explanation
field type default meaning
anchor object id required the scoped object the camera rides
offset 3-tuple required where the camera sits relative to it, meters
frame Local | World Local whether offset turns with the anchor's hull or stays on world axes
look_at Anchor | Point((x,y,z)) | Object("id") Anchor what the shot faces
blend Option blend None omitted is a CUT; see SetCamera for the move

Local composes the same shot whatever heading the anchor is on - an over-the-shoulder chase. World composes the same shot whatever the anchor is DOING, which is what you want while the player is still flying: their heading is not yours to choose.

look_at: Object follows a live object and falls back to the anchor if that object dies mid-shot; look_at: Point is fixed world space and survives whatever was standing there. Frame something that is about to be destroyed with Point, not Object.

Camera authority only. It never steers or stops the ship it rides and does not change player input authority. Like SetCamera it drops free-fly control and re-enforces the pose every frame; unlike SetCamera there is something to hand back to, so pair it with ReleaseCamera. Pair a staged shot that must own gameplay input with SuspendPlayerControl and explicitly resume when the shot returns control. An anchor id that names nothing warns and leaves the camera alone.

ReleaseCamera

Drop every scripted camera override and hand the view back to the player's normal chase rig.

ReleaseCamera(()),
Show explanation

No fields. Takes off both SetCamera's fixed pose and SetCameraAnchor's ride, whichever is on. The chase rig never stopped solving underneath, so the shot ends and the player is looking out of their own ship again - there is no restore pose to author.

Harmless when nothing is pinned. This is camera-only: it does not implicitly resume player control.

SuspendPlayerControl

Block human flight, look, targeting, combat-stance, and weapon input while the simulation and scripted scene continue.

SuspendPlayerControl(()),
Show explanation

No fields. The action immediately clears held burn, RCS, rotation, radar, combat stance, thruster, turret, torpedo, and railgun intent so an input pressed before the cut cannot remain latched. Pause, menu, and an explicitly always-live cinematic-skip binding remain available. Physics, timers, AI, scripted ships, weapons already in flight, and camera actions continue.

Repeated suspension is harmless. Scenario teardown always restores control, but authored content should still pair each interval with ResumePlayerControl.

ResumePlayerControl

Restore human gameplay input after an explicit suspension.

ResumePlayerControl(()),
Show explanation

No fields. Repeated resume is harmless. This is input-only: pair it with ReleaseCamera when returning both view and control.

Screenshot

Capture the primary window to a PNG. A dev tool - pair SetCamera + settle frames + Screenshot to script a framed shot.

Screenshot((path: "shots/my_scene.png")),
Show explanation
field type default meaning
path string required output PNG path

Relative paths land under the NOVA_CAPTURE_DIR env var when set; parent directories are created.

SetSkybox

Swap the scenario's skybox mid-scenario.

SetSkybox((cubemap: "self://textures/nebula.png", brightness: Some(700.0))),
Show explanation
field type default meaning
cubemap asset ref required the new cubemap path ("self://textures/nebula.png")
brightness Option number None multiplier; None keeps the current brightness (initial scenario default 1000)

The install is deferred until the new image has loaded; a failed load leaves the sky unchanged (warned).