Extend the scenario engine
The scenario engine is config-open but code-closed. Authoring a scenario in RON
from the primitives that already exist is a data change and lives in its own
guide (see Create your first scenario). Adding a NEW
primitive - a new event, filter, action, or object kind - is a Rust change, and
each of the four follows one repeated recipe: define the config, wire it into
the matching dispatch enum, add the arm on the trait impl that fans out to it,
and export it. This is the “how to add” companion to
Scenario engine (the “what it is” reference); read that
first for the vocabulary (NovaEventWorld, handlers, filters, actions, scoped
objects).
The dispatch shape is identical everywhere: an enum variant carries a config
struct, a match arm on the trait impl delegates to that config’s own impl.
flowchart LR Variant["Config enum variant"] --> Arm["match arm on the trait impl"] Arm --> Impl["config's own trait impl"] Impl --> Effect["fires / filters / mutates"]
The NovaEventWorld seam
Read this once; recipes 2 and 3 depend on it. Filters and actions never touch
the Bevy World directly. They see only NovaEventWorld
(crates/nova_scenario/src/world.rs), the resource that holds scenario state:
variables, objectives, next_scenario, and a queue of deferred command
closures. What a filter or action may touch:
world.get_variable(key)/world.insert_variable(key, VariableLiteral)- the typed scenario variables.world.push_objective(ObjectiveActionConfig)/world.remove_objective(id)- HUD objectives (synced write-on-diff intoGameObjectives).world.next_scenario = Some(NextScenarioActionConfig { .. })- queue a scenario switch.world.push_command(|commands| ...)- defer anything that needs real world access (spawning, querying entities, resource mutation). The closure gets a&mut Commands; for a full&mut World(id -> Entity lookups) queue acommands.queue(move |world: &mut World| ...)inside it, the shapeDespawnScenarioObjectActionConfiguses.
Nothing runs against the world synchronously. Each frame
NovaEventWorld::state_to_world_system (in world.rs) syncs objectives into
GameObjectives, runs a queued non-lingering NextScenario switch, then drains
the command queue - so every push_command closure lands at frame end, in
order. The unit tests in the actions/ submodules exercise exactly this:
mutate a NovaEventWorld, call
NovaEventWorld::state_to_world_system(&mut world) to
drain, then assert on the world (see
despawn_action_removes_the_scoped_object_by_id in actions/spawn.rs).
Recipe 1: add an event kind
An event is fired somewhere in the engine, and scenarios react to it through a
handler. Adding one is two files: the event type (nova_events) and its config
variant (nova_scenario), plus the firing site.
-
In
crates/nova_events/src/lib.rsdefine the marker event and its info struct with theEventKindderive. The info is what a handler’s filters read; give it the pair shape (id,other_id,other_type_name) if it targets an object so theEntityfilter composes like the others.#![allow(unused)] fn main() { #[derive(Debug, Clone, EventKind, Reflect)] #[event_name("ondocked")] #[event_info(OnDockedEventInfo)] pub struct OnDockedEvent; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, Reflect)] pub struct OnDockedEventInfo { #[serde(rename = "id")] pub id: String, #[serde(rename = "other_id")] pub other_id: String, #[serde(rename = "other_type_name")] pub other_type_name: String, } }Export both from the
nova_eventsprelude (thepub use super::{...}block at the top oflib.rs). -
In
crates/nova_scenario/src/events.rsadd the variant toEventConfig(grep forenum EventConfig) and the arm toimpl From<EventConfig> for EventHandler<NovaEventWorld>(grep forimpl From<EventConfig>):#![allow(unused)] fn main() { pub enum EventConfig { // ... OnDocked, } // in the From match: EventConfig::OnDocked => EventHandler::new::<OnDockedEvent>(), } -
Fire it. Engine-driven events fire from
crates/nova_scenario/src/loader/withcommands.fire::<OnDockedEvent>(OnDockedEventInfo { .. })(see theOnStartsite inloader/lifecycle.rs,OnUpdateinloader/clock.rs, and orbit-lifecycle/the lock events inloader/trackers.rs); object-local events (an area entering/leaving) fire from the object’s own observer, the wayobjects/area.rsfiresOnEnterEventfrom its own trigger. A kind may also fire from a system it owns:objects/asteroid_carve.rsfiresOnDestroyedEventout ofcarve_asteroid_fieldswhen a rock’s field is exhausted, because “destroyed” there is a geometry test rather than a health-zero marker.
EventConfig is Copy and derives serde, so the new variant is authorable from
RON with no extra work.
Recipe 2: add an event filter
A filter gates whether a handler’s actions run; all filters on a handler must
pass. Everything lives in crates/nova_scenario/src/filters.rs.
-
Define the config struct and its
EventFilter<NovaEventWorld>impl.filterreturns a bool and may readworld(variables) andinfo(the fired event data); it must not mutate.#![allow(unused)] fn main() { #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct VariablePresentFilterConfig { pub key: String, } impl EventFilter<NovaEventWorld> for VariablePresentFilterConfig { fn filter(&self, world: &NovaEventWorld, _: &GameEventInfo) -> bool { world.get_variable(&self.key).is_some() } } } -
Add the variant to
EventFilterConfig(grep forenum EventFilterConfig) and the arm toimpl EventFilter<NovaEventWorld> for EventFilterConfig(grep forimpl EventFilter<NovaEventWorld> for EventFilterConfig):#![allow(unused)] fn main() { pub enum EventFilterConfig { Entity(EntityFilterConfig), Conditional(ConditionalFilterConfig), Expression(ExpressionFilterConfig), VariablePresent(VariablePresentFilterConfig), } // in the filter match: EventFilterConfig::VariablePresent(config) => config.filter(world, info), } -
Export the config struct from the module
prelude(thepub use super::{...}block at the top offilters.rs). -
Make it authorable in the editor (see Two surfaces author the vocabulary): derive
Reflecton the config, tag any string that names something with theNamesattribute, and add theFilterChoicevariant incrates/nova_editor/src/event.rs-ALL,label,stem,operands,stock, plus thefilter_choice,filter_configandfilter_config_mutarms. The matches are exhaustive, so the compiler names every one of them.
Recipe 3: add an event action
An action runs when a handler passes, in order. Everything lives in
crates/nova_scenario/src/actions/: the EventActionConfig enum and its
dispatch in actions/mod.rs, each action’s config and impl in the submodule
for what it touches (view.rs, flow.rs, mission.rs, sequence.rs,
ship.rs, spawn.rs, timer.rs).
-
Define the config struct and its
EventAction<NovaEventWorld>impl.fn action(&self, world: &mut NovaEventWorld, info: &GameEventInfo)mutates the seam and nothing else - useworld.insert_variable,world.push_objective,world.next_scenario, orworld.push_command(...)for world access. Anything needing an id -> Entity lookup queues acommands.queue(move |world: &mut World| ...)inside the pushed command, scoped withWith<ScenarioScopedMarker>(a raw id match would also hit ship sections that carryEntityId).#![allow(unused)] fn main() { #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct VariableClearActionConfig { pub key: String, } impl EventAction<NovaEventWorld> for VariableClearActionConfig { fn action(&self, world: &mut NovaEventWorld, _: &GameEventInfo) { world.insert_variable(self.key.clone(), VariableLiteral::Boolean(false)); } } } -
Add the variant to
EventActionConfig(grep forenum EventActionConfig) and the arm toimpl EventAction<NovaEventWorld> for EventActionConfig(grep forimpl EventAction<NovaEventWorld> for EventActionConfig):#![allow(unused)] fn main() { pub enum EventActionConfig { // ... VariableClear(VariableClearActionConfig), } // in the action match: EventActionConfig::VariableClear(config) => { config.action(world, info); } } -
Export the config struct from the
actions/mod.rspreludeblock. -
Make it authorable in the editor, as in recipe 2: derive
Reflect, tag the naming strings, and add theActionChoicevariant incrates/nova_editor/src/event.rs-ALL,label,stem,stock, plus theaction_choice,leaf_configandleaf_config_mutarms. OnlySequenceholds children; a new leaf action needs nothing from the tree.
Templates: the tests at the bottom of the actions/ submodules are the
pattern to copy -
despawn_action_removes_the_scoped_object_by_id (actions/spawn.rs; queued
world lookup, scoped),
hint_emphasis_actions_drive_the_resource (actions/mission.rs; resource
mutation through the drain),
objective_marker_attach_and_detach_drive_the_component (actions/mission.rs;
component insert/remove).
Each fires the action into a NovaEventWorld, drains with
NovaEventWorld::state_to_world_system, then asserts.
Recipe 4: add a scenario object kind
A scenario object is a scoped entity spawned by SpawnScenarioObject; the
kind decides its own body (or none - four of the six shipped kinds are
static). Model it on crates/nova_scenario/src/objects/beacon.rs or
salvage.rs. Do NOT model it on the asteroid: it is the least representative
kind in the directory, split across three modules with two plugins, carrying no
Health and outside the integrity graph entirely.
A kind is not required to be a physical body.
beacon.rsdeclaresRigidBody::Static(the base bundle supplies no body), andlight.rsis a pure-render kind: it splits config from component with anAddobserver so therenderflag can skip the Bevy light entirely for headless tools. Note that scene lighting itself is authored content - a scenario with noLightobject renders black, so any new example or fixture that renders needs one.
-
Add the type-name const to
crates/nova_events/src/lib.rs, besideEntityTypeNameand the other*_TYPE_NAMEvalues, and export it from that crate’s prelude. It goes there, not beside the object, because a reader that matches on it -nova_os_ui’s map,nova_gameplay- must not depend onnova_scenarioto name a kind (CONVENTIONS, Nova 5).#![allow(unused)] fn main() { /// [`EntityTypeName`] value for an authored mine. pub const MINE_TYPE_NAME: &str = "mine"; } -
Create
crates/nova_scenario/src/objects/<kind>.rs. It holds a config struct, a marker component, a<kind>_scenario_object(config) -> impl Bundlebuilder, and (optionally) aPluginfor any observers/systems the kind needs. The bundle carries the marker plus anEntityTypeName; the sharedbase_scenario_object(id, name, transform, visibility,ScenarioScopedMarker) is added by the spawn path, not here. It deliberately carries NO body - each kind declares its ownRigidBody(the asteroid addsDynamic+TransformInterpolation; four of the six kinds are static).#![allow(unused)] fn main() { #[derive(Component, Clone, Debug, Reflect)] pub struct MineMarker; #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct MineConfig { pub radius: f32, pub damage: f32, } pub fn mine_scenario_object(config: MineConfig) -> impl Bundle { ( MineMarker, EntityTypeName::new(MINE_TYPE_NAME), // ... the kind's own components ) } pub mod prelude { pub use super::{mine_scenario_object, MineConfig, MineMarker}; } } -
Register the module in
crates/nova_scenario/src/objects/mod.rs: addpub mod <kind>;, re-export<kind>::prelude::*from themod.rsprelude, and if the kind has a plugin add it inScenarioObjectsPlugin::build(likeAsteroidPlugin, which takesrender). -
In
crates/nova_scenario/src/actions/spawn.rsadd the variant toScenarioObjectKind(grep forenum ScenarioObjectKind) and the spawn arm inimpl EventAction<NovaEventWorld> for ScenarioObjectConfig:#![allow(unused)] fn main() { pub enum ScenarioObjectKind { Asteroid(AsteroidConfig), Spaceship(SpaceshipConfig), Beacon(BeaconConfig), SalvageCrate(SalvageCrateConfig), Light(LightConfig), Mine(MineConfig), } // in the spawn match: ScenarioObjectKind::Mine(config) => { entity_commands.insert(mine_scenario_object(config.clone())); } }
The new kind is now spawnable from any handler, in RON, as a
SpawnScenarioObject action:
SpawnScenarioObject(ScenarioObjectConfig(
base: BaseScenarioObjectConfig(
id: "mine_1",
name: "Proximity Mine",
position: (0.0, 0.0, -200.0),
rotation: (0.0, 0.0, 0.0, 1.0),
),
kind: Mine(MineConfig(radius: 5.0, damage: 40.0)),
))
Two surfaces author the vocabulary
A RON file is one way to write a handler; the editor’s EVENTS mode is the other,
and it draws every row it shows by REFLECTION off the same config structs. So a
config that is not Reflect is a construct the editor cannot show, and a string
field with no Names
attribute is a blank box where the panel could have offered the ids the document
actually spawns.
AN AssetRef<A> FIELD PICKS ITS OWN FILE. The panel reads the sort off the
type, so a field typed AssetRef<Image> offers the images the installed bundles
DECLARE in their resources lists, written as the dep://<mod>/<file> ref that
resolves - and marks a dep:// path no bundle ships. Do not add
#[reflect(ignore)] to an asset field a builder is meant to change: an ignored
field has no row at all.
A FIELD’S DOC COMMENT IS ITS TOOLTIP. nova_editor builds against
bevy/reflect_documentation, so the first paragraph of the /// above a field
- or above the variant a choice row stands on - is what the panel says when the pointer rests on that row. Write it for the person filling the box in, not for the person calling the constructor: a field nobody documented is a row the editor cannot explain.
What each of the four recipes owes the editor:
| Recipe | What the editor needs |
|---|---|
| Event kind | Nothing. EventConfig is a Reflect enum of unit variants and the handler’s trigger row is walked off it, so a new event appears in the list. |
| Filter | Reflect + Names on the config, and a FilterChoice variant with its stock value. |
| Action | Reflect + Names on the config, and an ActionChoice variant with its stock value. |
| Object kind | Reflect + Names on the config, an ObjectChoice variant in crates/nova_editor/src/node.rs to place it with, and the arms the compiler then asks for (glyph, preview, stage, inspect). |
stock is what the kind switch puts on a node the moment it is switched TO -
a valid config with empty ids, never a Default that lowers into something the
lint refuses.
Checklist
Whichever recipe you follow, the change is done when: the config struct derives
Clone, Debug, Reflect and the serde pair; every field says what it is for
in a doc comment; every string that names something carries its Names
attribute; every file a builder may change is an AssetRef<A> the reflection
can see; the dispatch enum has the variant; the
trait impl has the delegating arm; the type is exported from its module prelude;
the editor has the choice variant (events excepted); and (for an event)
something fires it. Then it is reachable from code-built scenarios, from a RON
data file, and from the editor.
Find it in the code
- Events:
EventConfig-crates/nova_scenario/src/events.rs; event types and theEventKindderive -crates/nova_events/src/lib.rs. - Filters:
EventFilterConfig-crates/nova_scenario/src/filters.rs. - Actions:
EventActionConfig-crates/nova_scenario/src/actions/mod.rs(submodules: flow, mission, ship, spawn, timer, view). - Objects:
ScenarioObjectKind-crates/nova_scenario/src/actions/spawn.rs; kind modules undercrates/nova_scenario/src/objects/. - The seam:
NovaEventWorld-crates/nova_scenario/src/world.rs. - What a string names:
Names-crates/nova_scenario/src/names.rs; the editor’s choices:FilterChoice,ActionChoice-crates/nova_editor/src/event.rs. - API detail:
cargo doc --open -p nova_scenario(event engine:-p nova_events).