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 26 at a glance:
| action | group | what it does |
|---|---|---|
SpawnScenarioObject |
world | spawn one object: asteroid, ship, beacon, crate, 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 |
StoryMessage |
mission | queue a speaker-attributed comms line |
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 |
Sequence |
pacing | run an ordered list of beats, each behind its own delay or gate |
Outcome |
flow | show the VICTORY / DEFEAT banner and freeze the sim behind it |
NextScenario |
flow | queue a switch to another scenario by id |
SetSpeedCap |
ship state | install, update or remove the soft manual-speed governor |
SetControllerVerb |
ship state | grant or withhold one flight verb on a ship's controller |
SetAllegiance |
ship state | overwrite a ship's side at runtime |
ForceTorpedoLaunch |
ship state | order a ship's torpedo bays to launch at a named target |
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 |
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 six kinds are the
Scenario objects reference.
SpawnScenarioObject((
base: (id: "rock_1", name: "Rock", position: (10.0, 0.0, -40.0), rotation: (0.0, 0.0, 0.0, 1.0)),
kind: Asteroid((radius: 5.0, texture: "dep://base/textures/asteroid.png", invulnerable: false)),
)),
Show explanation
| field | type | default | meaning |
|---|---|---|---|
base |
object base | required | identity + pose (below) |
kind |
object kind | required | Anchor((..)) / Asteroid((..)) / Spaceship((..)) / Beacon((..)) / SalvageCrate((..)) / 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 |
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: (-100.0, -20.0, -100.0), max: (100.0, 20.0, 100.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: 1.0, texture: "dep://base/textures/asteroid.png", invulnerable: false)),
),
asteroid_radius: Some((1.0, 3.0)),
min_separation: Some(32.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 (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 [lo, hi) |
min_separation |
Option number |
None |
minimum centre-to-centre distance (world units) 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: ..) |
center defaults to the origin |
horizontal annulus: uniform angle, radius in [inner, outer], y in [y_min, y_max] |
Set min_separation on any field of SOLID bodies: uniform sampling WILL
nest rocks inside each other, and two overlapping dynamic bodies shove apart
violently on the first physics step. Size it as the two widest bodies side
by side - for asteroids that is NOT radius: the noise mesh reaches up to
6x the nominal radius. 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 salvage crate on pickup.
DespawnScenarioObject((id: "crate_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, -100.0), rotation: (0.0, 0.0, 0.0, 1.0), radius: 10.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 |
rotation |
4-tuple | required | rotation (cosmetic for a sphere) |
radius |
number | required | sensor radius, world units |
Works mid-scenario, and works even when created AROUND a body already inside (the entry still fires).
Beacons with area_radius and salvage crates are their OWN areas - no
separate CreateScenarioArea needed for those.
Mission & story
Objective
Post a HUD objective. Objectives state goals; comms lines
(StoryMessage) 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) |
StoryMessage
A speaker-attributed line for the HUD comms stack (bottom-left, arrival order, ~8 s hold each, at most three visible). One line per beat is the style; the queue is the safety net.
StoryMessage((speaker: "Foreman Okono", text: "Strip it clean, Kestrel.", dwell: Some(12.0))),
Show explanation
| field | type | default | meaning |
|---|---|---|---|
speaker |
string | required | the SPEAKER > prefix |
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 |
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"; 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 verb (SetControllerVerb does).
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 |
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: [
StoryMessage((
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.
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 convoy is lost."))),
NextScenario((scenario_id: "lifeline", 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: "broadside_gunship", linger: true)),
Show explanation
Three gears:
- Hard cut -
linger: false, no delay: switches on the next sync. Menu-scene plumbing. - 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.
Ship state
SetSpeedCap
Install, update or remove the soft manual-speed governor on a scoped ship.
SetSpeedCap((id: "player_spaceship", cap: Some(25.0))),
SetSpeedCap((id: "player_spaceship")) // release the governor
Show explanation
| field | type | default | meaning |
|---|---|---|---|
id |
string | required | scoped ship root |
cap |
Option number |
None |
Some(25.0) installs/updates the cap (u/s); None or omitted REMOVES it |
SetControllerVerb
Grant or withhold one flight verb on a scoped ship's controller - the
tutorial-progression primitive (the Shakedown Run starts with Goto
withheld and grants it at the beacon).
SetControllerVerb((id: "player_spaceship", verb: Goto, enabled: true)),
// The battery answers a salvo by itself - until this takes it away.
SetControllerVerb((id: "player_spaceship", verb: PointDefense, enabled: false)),
Show explanation
| field | type | default | meaning |
|---|---|---|---|
id |
string | required | scoped ship root |
verb |
verb | required | Stop / Goto / Orbit / Lock / Rcs / PointDefense (bare enum, no quotes). Convention: never withhold Stop - an engaged autopilot should always be cancelable |
enabled |
bool | required | true grants, false withholds |
The spawn-time twin is the DisableVerb section modification (see
Spaceship); this action is 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) |
ForceTorpedoLaunch
Order a scoped ship's torpedo bays to launch at a named target - the scripted counterpart of the AI's launch decision, for controller-less emplacements fired by timers ("the battery shoots every N seconds").
ForceTorpedoLaunch((id: "battery_west", target: "patrol_ship")),
Show explanation
| field | type | default | meaning |
|---|---|---|---|
id |
string | required | scoped ship root whose bays launch; a dangling id is a lint Error |
target |
string | required | scoped ship root the ordnance homes on; a dangling id is a lint Error |
Every torpedo bay on the ship gets a one-shot order; each bay's own cooldown and ammo still time the actual launch, and the ordnance is committed to the target like an AI launch (so hostile point defense can engage it). The AI's launch gates (range envelope, hull-forward cone, line of sight, the 10 s AI cadence) do NOT apply - the script is the decision.
A missing target skips the launch entirely (no dumb-fire duds while the
target is mid-respawn). On an AI-controlled ship the AI rewrites the bay
trigger every frame and wins; give scripted batteries
controller: None.
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, 30.0, 80.0), look_at: (0.0, 0.0, 0.0))),
Show explanation
| field | type | default | meaning |
|---|---|---|---|
position |
3-tuple | required | world camera position |
look_at |
3-tuple | required | world point to face (up is +Y) |
Part of the screenshot/photo surface; no scenario camera present is a warn no-op.
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).