Create / Mod files / Scenario files
Scenario files
A scenario defines one playable mission, continuation, or menu backdrop. Its
events list is the script: events decide when a handler runs, filters decide
whether it applies, and actions change the world.
For a guided first mission, start with Create your first scenario. This page is the scenario file reference.
File shape
A scenario can have its own content file:
[
Scenario((
id: "freight_wars_arrival",
name: "Freight Wars: Arrival",
description: "Clear the shipping lane.",
cubemap: "dep://base/textures/cubemap.png",
events: [
(
name: OnStart,
actions: [
Objective((
id: "clear_lane",
message: "Clear the shipping lane.",
)),
],
),
],
)),
]
List the file in the bundle manifest. See Mod files for the folder and bundle shape.
Fields
| field | type | default | meaning |
|---|---|---|---|
id |
string | required | Stable scenario key used by campaigns and NextScenario. Prefix new ids with your mod id. |
name |
string | required | Name shown in the Scenarios menu. |
description |
string | required | Details shown for the selected scenario. |
cubemap |
asset ref | required | Skybox image, such as dep://base/textures/cubemap.png or self://textures/sky.png. |
thumbnail |
Option asset ref |
None |
Plain 2D menu image, written as Some("self://thumbnails/x.png"). Do not use a cubemap. |
role |
role | Chapter |
What the scenario IS - see Roles. Chapter is a level and the only role the Scenarios list shows. |
world |
world | Void |
Which world the scenario runs on - see World. Omit it for a scene of authored objects only. |
watches |
list | [] |
Read-only queries sampled into auto-updating variables, entries of (variable: "...", query: ...). See Queries and watched variables. |
events |
list of handlers | [] |
Scenario script. Empty is valid but does nothing. |
A skybox is six square faces stacked vertically in one PNG, with a .png.meta
sidecar that cuts the stack into six layers (see
Base content). Size the faces for the memory they cost, not
for the detail the source can give: every face is uploaded uncompressed, and the
sidecar keeps the decoded pixels in main memory as well, so 1024 px faces cost
24 MB of video memory AND 24 MB of main memory, and 4096 px faces cost 384 MB of
each. On the web build both halves come out of the same budget. The base skies
ship at 1024.
Roles
role says what kind of thing the scenario is. It decides where the game offers
it, and it is checked: a campaign may only list Chapters, and a lesson may only
practise in a Lesson.
| role | what it is | where it is offered |
|---|---|---|
Chapter |
A level. The default, and what you want unless you are writing menu scenery or a training range. | The Scenarios list, and campaigns. |
Backdrop |
Menu scenery: it poses its own camera, hands the player no ship, and reports when its act is done. | The main-menu backdrop rotation, and nowhere else. |
Lesson |
A focused practice range for one handbook lesson. | That lesson's Practice button, and nowhere else. |
Omit role and the scenario is a Chapter.
A menu backdrop POSES ITS OWN CAMERA: author a
SetCamera in its OnStart (the reference shot is
position: (0, 570, 1920) in meters, looking at the origin, which frames
about 1,060 m either side of the origin in a 4:3 window). A backdrop without
one is a content Error and never enters the menu rotation - the menu derives
no pose of its own.
A menu backdrop REPORTS WHEN ITS ACT IS DONE: author a
BackdropDone where the act ends (a scene with no
natural ending times itself out). The menu then picks the next backdrop. A
backdrop without one, or with a NextScenario, is a content Error.
A practice range TEACHES ONE THING, and owes the player four things the four ranges in the handbook show:
- It hands over only the verbs its lesson names. Withhold the rest in the
player ship's
capabilitiesblock, so the momentum range carries no flight computer at all and the keybind chip for a verb the player must not press is never drawn. - It posts ONE objective at a time, one short imperative line, completed before
the next is posted. The objective chip is a notification that leaves once it
has been read (
ObjectiveChipHoldis the one exception), so the REASON goes to the comms stack in aNarrativeCuefrom the range's own voice, not onto the board. - It lights the verb in hand with
HintEmphasisSet, and clears it withHintEmphasisClearwhen that beat is done. - It ENDS. Every path reaches an
Outcome: a Victory with nothing queued, so the overlay sends the player back to the handbook, and a Defeat thatNextScenarios the range itself, so the overlay offers Retry.
World
world says which world the scenario runs on. It is independent of role.
| world | what it is | who starts it |
|---|---|---|
Void |
No streamed world: the objects your handlers spawn are the whole scene. The default. | Whatever the role says. |
Generated((seed: None)) |
The generated open world, streamed around the one player ship your OnStart spawns, from the seed the player typed on New Game. Only this world may use SpawnStartRole, which spawns the start role the player picked beside the seed's starting station, the nearest allied one, where the world docks it. |
New Game and Load only: the Scenarios list hides it, a campaign may not list it, and --scenario refuses it. The base open_world start. |
Generated((seed: Some(42))) |
The same generator on a fixed seed: a bounded world that always comes up the same. Session-only: it makes no saved world, Retry reloads it, and Load last save is not offered. | The Scenarios list and campaigns, like any chapter. |
A generated world must spawn EXACTLY ONE player ship in OnStart; zero or
two is a content Error. Only a chapter may take seed: None: a role: Backdrop or role: Lesson scenario with it is a content Error, because the
menu and the handbook start those with no seed.
Generated also takes an optional bounds: Some((position: (0, 0, 0), radius: 20000)), a sphere in meters the player is meant to stay in. The
loader spawns it as a scenario area with the id bounds before OnStart
runs, so leaving it is an ordinary OnExit with id: Some("bounds"), and what happens then is yours to author: the engine ends
nothing on its own, and the world keeps streaming sectors past the edge.
world: Generated((
seed: Some(1701),
bounds: Some((position: (0.0, 0.0, 0.0), radius: 2000.0)),
)),
events: [
// ...OnStart spawns the one player ship...
(
name: OnExit,
filters: [Entity((id: Some("bounds"), other_id: Some("player_spaceship")))],
actions: [Outcome((outcome: Defeat, message: Some("You crossed the line.")))],
),
],
Rules the content lint checks: the radius may not exceed 80,000 m, the half
edge of the streamed window (Error); no handler may spawn its own object or
area named bounds (Error); a bounds that no OnExit handler reads does
nothing and is a Warning. The example mod's example_bounds is the copyable
instance.
Handler shape
Each event entry is one handler:
| field | type | default | meaning |
|---|---|---|---|
name |
event kind | required | Trigger such as OnStart, OnUpdate, or OnDestroyed. |
once |
bool | false |
Retire this handler the first time its filters pass. |
filters |
list | [] |
Every filter must pass. An empty list always passes. |
actions |
list | [] |
Commands run in listed order after the filters pass. |
(
name: OnDestroyed,
once: true,
filters: [
Entity((id: Some("lane_blocker"))),
],
actions: [
ObjectiveComplete((id: "clear_lane")),
Outcome((
outcome: Victory,
message: Some("The shipping lane is open."),
)),
],
),
once - a beat that happens one time
A beat that can only happen once says so, and the engine holds the fact.
Without once the same handler needs a flag of its own: a VariableSet in
OnStart to seed it, a filter reading it, and an action writing it - three
lines of ceremony that are about the machine, not about the game.
once retires the handler the first time its filters PASS, not the first time
its event fires. A refused event leaves it live, so a beat waiting on a
condition still gets every later chance at it.
Show explanation
Use it for anything with a single occurrence: a story line, an objective post, a one-time spawn, an act transition, an outcome. Leave it OFF for anything that genuinely repeats - a per-frame HUD readout, a re-armable warning, a cycle a player can ride more than once.
Keep a variable only where it is a SIGNAL another handler reads ("wave two is
on the board", "the convoy lost a ship"). Delete it where its only reader was
its own filter - that is what once replaces.
A retired handler is gone: it stops being walked every frame, and it cannot fire again even if the same event repeats in the same frame.
Sequence - beats that follow each other
A run of beats that only wait for LATER is one
Sequence action holding the ordered steps. The
engine holds the cursor, so the chain needs no step counter, no stamped
deadline, and no handler per beat.
Sequence((
key: "opening",
steps: [
(after: Some(2.0), actions: [ /* first line */ ]),
(after: Some(8.4), actions: [ /* second line */ ]),
],
)),
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. A step runs when its wait ends; only a handler re-checks.
Scenario scripting chapters
- Events - when handlers run and what entity data they carry.
- Filters - entity matching, expression conditions, and logic.
- Actions - objectives, spawning, story, flow, and state changes.
- Scenario objects - asteroids, planets, ships, beacons, and lights.
- Variables and expressions - counters, conditions, clocks, and state machines.
These constructs apply only inside a scenario's events list. Campaign and
section items do not use them.
Lifecycle
- The game tears down the previous scenario and loads the selected scenario.
- All handlers are registered, then
OnStartfires. - Events fire while the scenario is active. Filters gate actions, and a
oncehandler retires the first time its filters pass. - A switch or retry removes all scenario-scoped objects and clears variables, objectives, story messages, HUD readouts, and pending transitions.
Nothing persists automatically between scenarios. Put shared progression in separate content or design each scenario to start from a complete state.
Add or replace a scenario
A new id adds a scenario. Reusing an existing id replaces the whole scenario; it is not a field-level patch. Names and filenames do not affect matching.
Every scenario you add is a row in the Scenarios list, unless it declares a
role other than Chapter. A continuation chapter is a row too: use a campaign
to group the chapters under one header, and NextScenario to play on into the
next one.
Check it
nix develop --command cargo run content lint --target path/to/your-mod
nix develop --command cargo run --features dev
The lint checks target ids, duplicate object ids, prototype references, ship geometry, reserved variables, and other errors that valid RON alone cannot find. In the game, enable the mod, open Scenarios, and play the visible entry.
Common mistakes
- A scenario with no
Lightobject renders black. - Unknown fields and misspelled enum variants are parse errors.
- Optional values require
Some(...). - Initialize variables before filters read them.
- Spawn an object before an action targets its id.
role: Backdropis menu scenery, not a level. It is left out of the Scenarios list, it must pose its own camera withSetCameraand report its finished act withBackdropDone, and a campaign that names one is a lint error.role: Lessonis a practice range, reachable only from the lesson whose Practice button names it. A campaign that names one is a lint error too, and a range no lesson practises in is a warning.- A mod cannot replace the base New Game selection. Use the Scenarios menu,
campaigns, or
NextScenario.