Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

The Nova Protocol developer book: how to run the project, how to extend it, and where things live. Source lives in docs/ at the repo root and builds with nix develop --command mdbook build.

This book is the LOOKUP layer: search a concept (the search icon, top left), land on a Concept index row, and leave knowing which crate, files and entry symbol to open. For API detail past that, run cargo doc --open -p <crate> locally - the book routes, rustdoc documents.

Reading order for a first visit:

  1. Building and running - toolchain, everyday commands, examples, the web build, releases.
  2. Project tour - the crate map and where to change X.
  3. The Architecture chapters for depth, then the Extending guides for your change.

Before quoting a millisecond at anyone, read Measuring performance.

What this book is not:

  • API detail. That is rustdoc: cargo doc --open, run locally. Every crate exposes a prelude; the book names modules, rustdoc documents them.
  • Player or creator documentation. Players read the wiki; mod authors read Create on the site. Keeping docs in sync maps every surface.

Concept index

The routing table. Search a concept (the search icon or S), open the named files, start reading at the entry symbol. Paths are repo-relative; every row was verified against the tree. For API detail run cargo doc --open -p <crate> locally - every crate exposes a prelude. For prose depth, follow the chapter linked at each group heading.

Boot and frame flow

Depth: Architecture.

ConceptCrate(s)Key filesEntry symbolOrientation
App boot, app assembly, plugin ordernova_corecrates/nova_core/src/lib.rsAppBuilderbuild() wires the whole plugin stack; game binary + CLI flags in src/main.rs.
Game states, pausenova_gameplay, nova_menucrates/nova_gameplay/src/lib.rs, crates/nova_menu/src/pause.rsGameStates, PauseStatesState enums + GameMode live in gameplay; ESC overlay, toggle_pause and the clock freeze in menu.
Frame flow: Update vs FixedUpdatenova_ship, nova_gameplaycrates/nova_ship/src/lib.rs, crates/nova_gameplay/src/plugin.rsSpaceshipSystemsNovaShipPlugin chains the set brackets identically in both schedules; avian physics runs in FixedPostUpdate. Which schedule your system goes in: the Architecture chapter.
Asset loading gate, mod mergenova_assetscrates/nova_assets/src/plugin.rs, crates/nova_assets/src/merge.rsGameAssetsPluginGameAssetsStates gates entry to the menu/game; register_bundles merges base + enabled mods into the Game* resources.

The ship

Depth: Ship sections internals.

ConceptCrate(s)Key filesEntry symbolOrientation
Ship building from sectionsnova_scenario, nova_shipcrates/nova_scenario/src/objects/spaceship.rs, crates/nova_ship/src/sections/base_section.rsinsert_spaceship_sectionsObserver on the ship root spawns one child per SectionKind config, resolving prototypes; SpaceshipConfig is the authored shape.
Section integrity: damage, disable, destroynova_gameplaycrates/nova_gameplay/src/integrity/mod.rs, crates/nova_gameplay/src/damage.rsNovaIntegrityPluginHealth store and the disable/destroy chain; typed DamageType + the travel rule (apply_damage, pierce_remainder). Health decides WHEN a body dies; the two readings below decide what it looks like.
How far gone a body looksnova_gameplaycrates/nova_gameplay/src/integrity/erosion.rsDamageLevelOne scalar, 0..1, derived from the entity’s OWN Health. Grades every whole-body effect.
Where a body was hit (marks, carve cost, merge)nova_gameplaycrates/nova_gameplay/src/integrity/carve.rsDamageMarks, mark_radiusSpheres in the body’s local frame, priced by what the hit ABSORBED at DAMAGE_PER_UNIT_VOLUME (8 hp per cubic unit); record_blast_marks cuts one crater per body.
Authored per-section damage looksnova_shipcrates/nova_ship/src/sections/damage_effects.rs, damage_cracks.rs, damage_sparks.rs, damage_plume.rsDamageEffects, fit_damage_effectsCracks/Sparks/Plume, one component each, default [Cracks]. No ship section ever loses geometry.
Carve debris: dust and severed piecesnova_gameplaycrates/nova_gameplay/src/integrity/spew.rs, crates/nova_gameplay/src/integrity/chunk.rsCarveSpew, ShardLook, spawn_carved_chunkShards are keyed on the WEAPON CLASS - kinetic and pierce chip, explosive does not; only a cut that SEVERED material spawns a real body, floored at CHUNK_MIN_VOLUME.
Severing, wreck fragments, structural collapsenova_shipcrates/nova_ship/src/sections/integrity.rsShipIntegrityPluginBuilds the section graph, splits disconnected structure (sever_disconnected_structures -> ShipWreckFragmentMarker), runs cascade_structural_collapse.
Neutralization (combat-dead)nova_gameplaycrates/nova_gameplay/src/integrity/neutralize.rsNeutralizedMarkerAn armed ship that loses all weapons, or the flight computer it had, fires OnNeutralizedEvent; the hull may survive.
Ship skins, styles, greeblesnova_shipcrates/nova_ship/src/sections/shell_skin.rs, crates/nova_ship/src/sections/skin_style.rs, crates/nova_ship/src/sections/skin_decor.rsShipSkinPluginCladding derived from structure; styles resolve by id (ShipStyleConfig, GameStyles); deterministic greeble scatter (scatter_decor).
Turrets, aimingnova_shipcrates/nova_ship/src/sections/turret_section/mod.rs, crates/nova_ship/src/sections/turret_section/aim.rsTurretSectionPluginAuthored joint tree; lead-intercept aim (update_turret_aim_point, muzzle_on_target); arcs and firing in sibling files.
Torpedoes, point defensenova_shipcrates/nova_ship/src/sections/torpedo_section/mod.rs, crates/nova_ship/src/input/point_defense/mod.rsTorpedoSectionPlugin, SpaceshipPointDefensePluginBay + guided round lifecycle (TorpedoGuidance, TorpedoBlast); PD splits target assignment from mount authority (borrowed player mounts).
Flight autopilot verbs (STOP, GOTO, ORBIT), PD attitude controllernova_shipcrates/nova_ship/src/flight/state.rs, crates/nova_ship/src/sections/controller_section.rs, crates/nova_ship/src/physics/pd_controller.rsFlightVerb, AutopilotController sections grant verbs; the flight layer flies them (NovaFlightPlugin); PDControllerPlugin is the attitude loop.
Radar locking, targetingnova_shipcrates/nova_ship/src/input/targeting/mod.rs, crates/nova_ship/src/input/targeting/state.rsSpaceshipTargetingPluginRadar search writes the two sticky lock slots on the ship root: TravelLock, CombatLock.
Gravity wellsnova_gameplaycrates/nova_gameplay/src/gravity.rsGravityWellInverse-square wells with an SOI cutoff; anchors and asteroids publish one.

Scenario and modding

Depth: Scenario engine.

ConceptCrate(s)Key filesEntry symbolOrientation
Scenario engine, mission scriptingnova_scenario, nova_eventscrates/nova_scenario/src/lib.rs, crates/nova_events/src/engine.rsNovaScenarioPluginA scenario is handlers = event + filters + actions over NovaEventWorld; the generic queue/dispatch (EventHandler) is nova_events.
Scenario events, filters, actions (modding events)nova_scenariocrates/nova_scenario/src/events.rs, crates/nova_scenario/src/filters.rs, crates/nova_scenario/src/actions/mod.rsEventConfig, EventFilterConfig, EventActionConfigOne dispatch enum each; actions fan out to the flow/mission/sequence/ship/spawn/timer/view submodules beside mod.rs.
Scenario variables, expressions, watchesnova_scenariocrates/nova_scenario/src/variables.rs, crates/nova_scenario/src/world.rsVariableExpressionNode, NovaEventWorldTyped literals + expression tree (VariableConditionNode for filters); watches sample typed world queries into variables.
Scenario objects (asteroid, spaceship, beacon, crate, light, anchor)nova_scenariocrates/nova_scenario/src/objects/mod.rs, crates/nova_scenario/src/actions/spawn.rsScenarioObjectsPlugin, ScenarioObjectKindOne module per kind under objects/; the spawn action dispatches on the kind enum.
Scenario loading, lifetime scoping, teardownnova_scenariocrates/nova_scenario/src/loader/mod.rs, crates/nova_scenario/src/loader/lifecycle.rsScenarioLoaderPluginLoadScenario/UnloadScenario observers; everything tagged ScenarioScopedMarker dies at teardown; scenario_is_live gates the ship sets.
Mod formats, bundles, portalnova_mod_format, nova_modding, nova_assetscrates/nova_mod_format/src/lib.rs, crates/nova_modding/src/lib.rs, crates/nova_assets/src/portal/mod.rsBundleManifest, Content, PortalPluginEngine-free serde wire types -> RON asset loaders -> portal fetch/verify/install. The static portal is generated by scripts/gen-portal.py over webmods/.
Content generation (base RON, builders, lint)nova_authoringcrates/nova_authoring/src/cli.rs, crates/nova_authoring/src/generation.rscli::maincargo run content gen serializes the base_content builders into the committed base *.content.ron; content lint validates any content tree. Never hand-edit generated RON.

Interface

ConceptCrate(s)Key filesEntry symbolOrientation
NOVA OS (terminal, shell, ship computer apps)nova_os, nova_os_uicrates/nova_os/src/command.rs, crates/nova_os/src/app.rs, crates/nova_os_ui/src/lib.rsNovaOsUiPluginPure model in nova_os (NovaOsCommandRegistry, NovaOsAppRuntime); the Tab CRT monitor and the map/ship apps in nova_os_ui.
Keybinds: the action table, rebinding, the capturenova_input, nova_menucrates/nova_input/src/registry.rs, crates/nova_input/src/poll.rs, crates/nova_menu/src/settings.rsInputBindings, InputSourcesOne table of named actions; each owner registers its own defaults, every rig is BUILT from it, and every rebind surface reads it. Settings’ Controls tab takes the next press through InputSources; overrides persist in settings_store.
Flight HUD widgetsnova_hudcrates/nova_hud/src/lib.rsNovaHudPluginOne module per instrument (crosshairs, ammo, markers, comms, keybind dock); reads the ship, never drives it.
Main menu, menu backdropsnova_menucrates/nova_menu/src/lib.rs, crates/nova_menu/src/ambience.rsNovaMenuPluginThe backdrop is a random menu_backdrop-flagged scenario drawn live (load_menu_ambience); NOVA_MENU_BACKDROP pins one id for capture.
Shared UI theme, widgets, skinsnova_uicrates/nova_ui/src/lib.rs, crates/nova_ui/src/widget/mod.rsNovaUiPluginTheme tokens, the UiSkin switch, and the widget factories every UI-drawing crate consumes.
Ship editor, link points, placementnova_editor, nova_shipcrates/nova_editor/src/lib.rs, crates/nova_editor/src/snap.rs, crates/nova_ship/src/sections/link_points.rsNovaEditorPluginSandbox build scene; snap_placement mates socket frames; the editor solver decides or refuses a drop.

Tooling

Depth: Automation harness, Measuring performance and Building and running.

ConceptCrate(s)Key filesEntry symbolOrientation
Automation harness, autopilot scripts, screenshot capturenova_autopilot, nova_debugcrates/nova_autopilot/src/autopilot.rs, crates/nova_debug/src/harness.rsAutopilotPluginEnv-armed step driver (NOVA_AUTOPILOT, NOVA_CAPTURE); Nova presets and the shoot capture idiom live in the debug harness module.
Probe, run reports, what an example claimsnova_probe, nova_probe_clicrates/nova_probe/src/capabilities/mod.rs, crates/nova_probe/src/contract.rs, crates/nova_probe_cli/src/native.rsNovaProbePluginOne bundle wires every capability; probe run/scenario/report (subcommands of the game binary, debug feature) spawn and grade runs. Web capture app: crates/nova_perf_web/src/main.rs.
Frame cost, scene census, the capture windownova_probecrates/nova_probe/src/capabilities/frametime.rs, framecost.rs, census.rsnova_frametime, nova_framecost, nova_censusWall-clock deltas over a fixed window, plus where the milliseconds went and what the scene contained. Knob table: cargo doc -p nova_probe.
World-state snapshot (read the world, not a render)nova_probecrates/nova_probe/src/capabilities/snapshot.rsnova_snapshot, probe_snapshotOne JSON object per snapshot: ships, sections, fixtures, weapon state, rounds in flight. Sorted and rounded, so two snapshots of one frozen frame are byte-identical.
WFC arena, generated hullsexamplesexamples/playable/wfc_arena.rs, examples/playable/shared/wfc.rswfc_hullWave-function collapse over real section prototypes into flyable hulls; the arena’s lobby/pause/result match flow sits in examples/playable/wfc_arena/.
Debug tooling (inspector, overlays, F12 screenshots)nova_debugcrates/nova_debug/src/lib.rsDebugPluginCompiled only under the debug feature; F11 overlay toggle, F12 screenshot, --norender, --debugdump.
Web preview, this book at /dev/scripts, webscripts/serve-web.sh, scripts/preview-web.sh, web/webpack.config.jsserve-web.shLive-serves site + /play/ + /mods/ + this book at /dev/, all watched; the preview script builds the static deploy shape.

Development

Toolchain

  • Rust nightly, pinned by rust-toolchain.toml (with rustfmt + clippy).
  • NixOS: nix develop gives the toolchain, the wasm32-unknown-unknown target, all system libs Bevy needs (udev, alsa, vulkan, X11/wayland), trunk, and sccache (see fast worktree builds below). Without Nix, install those yourself. Bare cargo is not on PATH under Nix: run every cargo/rust command via nix develop --command <cmd> (the commands below assume you are inside nix develop).
  • Playing it, as opposed to working on it: nix run builds and launches the packaged game with no checkout and no development shell. See The nix package.

Everyday commands

cargo run                         # the game (boots into the main menu)
cargo run -- --scenario broadside # the game, straight into one scenario
cargo run --features dev          # + debug tooling (inspector, wireframe)
cargo run --example system_scenario_grammar   # run an example
cargo build --release             # release profile: opt=s, lto, stripped
cargo check && cargo fmt          # before committing
cargo test --workspace            # full suite (CI runs this; skip locally unless asked)
cargo run content lint   # validate content: refs + balance + input overlaps (also: gen)
cargo run --features debug probe run system_player_path          # run-harness check (correctness + perf)

Notes that keep the suite honest and fast:

  • Use cargo test --workspace, never bare cargo test: unit tests live in the member crates, so the bare form runs almost nothing and gives false comfort.
  • cargo test takes ONE filter and one -p per invocation; separate runs for separate filters or packages.
  • For a timed headless example run, build first, then time only the run (cargo build --example X --features debug, then NOVA_AUTOPILOT=1 timeout N cargo run --example X ...). A cold build inside the timeout burns the window.
  • Struct-field changes: cargo check --workspace --all-targets, or examples and tests stay silently broken.

The dev profile uses opt-level = 1 for our code, 3 for dependencies: slow first build, fast iteration. split-debuginfo = "unpacked" + debug = "line-tables-only" keep link-time RAM around 20 GB instead of 40 (one Bevy-sized binary per test/example target); set debug = true temporarily if you need a debugger.

Worktree builds (fast via sccache): a fresh sprout worktree starts with an empty target/, but the devshell wires sccache as RUSTC_WRAPPER (with CARGO_INCREMENTAL=0, which sccache requires) so it does NOT pay a full cold build. sccache caches each rustc invocation’s output keyed by a hash of the source content plus flags plus compiler version, in a shared cache (~/.cache/sccache). Unchanged deps (bevy, avian, the whole pinned tree) are 100% cache hits across worktrees; only changed nova_* crates recompile.

Measured on 2026-07-21 (game binary, quiet host):

buildwall clocksccache stats
cold (empty cache)~6m45s (405s)517 misses / 0 hits
warm (cargo clean, same source)~38s517 hits / 0 misses (100%)

The warm number is what a fresh sprout worktree gets once the shared cache is warm. Recipe from a new worktree:

cd "$(sprout new <branch>)"
nix develop --command cargo build          # warm-cache: seconds, not minutes
nix develop --command sccache --show-stats # confirm the hit rate

Still do NOT point CARGO_TARGET_DIR at another checkout’s cache: cargo keys fingerprints on crate name + version + features + profile + rustc, NOT the source path, so two checkouts alias each other’s artifacts in a shared dir and a worktree binary can silently link another checkout’s code (the stale-binary incident). Each worktree keeps its OWN target/; sccache is the SAFE way to share compilation because its cache key IS the source content - there is no path where a worktree links code from different source. That content-keying is also why sccache is transparent to CI: an empty cache is just a cold build.

The devshell sets CARGO_INCREMENTAL=0 shell-wide (sccache is incompatible with incremental). This costs the main checkout’s iterative edit-rebuild loop its incremental speedup; the fresh-worktree-per-task agent workflow only ever does cold-shaped builds, so it is pure win there. A sprout-scoped variant (export the wrapper only in sprout shells, keep the main checkout on incremental) is possible as a nix.dotfiles follow-up if the main-checkout iteration cost bites.

Features

  • debug - the whole nova_debug plugin (inspector, wireframe, overlays) plus bevy/track_location.
  • dev - alias for debug.
  • trace - bevy/trace + bevy/trace_chrome for span traces; the probe harness builds --features debug,trace when it needs one.

Debug tooling

cargo run --features dev compiles in nova_debug’s DebugPlugin (crates/nova_debug/src/lib.rs), which adds the inspector, the wireframe toggle, and the section/gravity debug overlays. The overlays are gated on a DebugEnabled resource toggled at runtime with F11 (DEBUG_TOGGLE_KEYCODE), so they can be flipped off without a rebuild. Note the feature is spelled debug, with dev as an alias for it (root Cargo.toml); --features dev and --features debug are interchangeable.

DebugPlugin also binds F12 (SCREENSHOT_KEYCODE, crates/nova_debug/src/screenshot.rs) to a screenshot: it captures the primary window and saves it to your Downloads directory as <unix-millis>.png. The capture is intentionally not gated on DebugEnabled, so it works whether or not the overlays are shown. Native only: the module is compiled out under target_arch = "wasm32", which has neither a Downloads directory nor a wall clock.

Three debug-only CLI flags exist, all parsed in src/main.rs and all compiled in only under the debug feature. Two of them are the OUTPUTS-OFF pair - --norender drops the renderer, --mute silences the speakers - and each has an environment twin an example can be armed with, since an example has no command line of its own:

  • --norender - build the app through AppBuilder::headless(): no wgpu device, no window, no winit event loop, and none of the visual game plugins. The main schedule still ticks, so the simulation runs and a probe capture still counts frames - CPU ones, with render_world and gpu reading zero.

    Nothing in a headless app ends the run. There is no window to close and no input of any kind, so it needs a driver: --scenario <id> under NOVA_AUTOPILOT, or probe scenario. A bare --norender ticks until it is killed, and that is by design rather than a hang to report.

    An EXAMPLE has no command line of its own, so the flag cannot reach it. The environment does: NOVA_NORENDER=1 makes every AppBuilder::new() in the process assemble the same headless app, which is how a range runs without a GPU. Set to anything, including empty; unset means render. AppBuilder::headless() ignores it, being already headless.

    NOVA_AUTOPILOT=1 NOVA_NORENDER=1 cargo run --features debug --example stress_point_defense
    cargo run --features debug probe run stress_point_defense --norender
    

    Headless is a speed option, not a substitute for a rendered run. With no device there is nothing to break: a duplicate-component panic, a material or pipeline failure, and the async-compile SIGSEGV synchronous_pipeline_compilation exists to prevent are all invisible headless, and cargo check does not see them either. Only a rendered run does. Run ranges headless for speed if you like; keep a rendered set as the canary.

  • --mute - zero the audio output. The other half of the outputs-off pair: Xvfb hides the window but not the speakers, and nobody listens to a scripted run. It inserts HarnessMute(true) after the builder, so it wins over whatever the environment resolved. The volume SETTING is untouched, so persistence and the settings menu never see it, and a muted run says nova audio: output muted for this run once at startup.

    The environment twin is NOVA_MUTE, which an example reads through HarnessMute::from_env: set to anything but 0 it mutes, NOVA_MUTE=0 forces sound even under a harness, and unset it mutes iff a harness variable (NOVA_AUTOPILOT, NOVA_CAPTURE) is set.

    cargo run --features debug -- --mute --scenario asteroid_field
    NOVA_MUTE=1 cargo run --features debug --example stress_bullets
    
  • --debugdump - print the system schedule graph (via bevy_mod_debugdump) and exit. It dumps the Update schedule (debugdump in crates/nova_debug/src/lib.rs).

Logging

The filter is built by log_filter_str in crates/nova_core/src/lib.rs. A --features debug run puts the nova crates at DEBUG; a release run leaves them at the plugin’s INFO default.

A new crate needs no filter change. Every workspace crate is named nova_* and EnvFilter matches a directive’s target by PREFIX, so the single nova= directive covers all of them. Do not add a per-crate directive: the list this replaced named nine of twenty-two crates, and the thirteen it missed sat silently at INFO while their neighbours were at DEBUG.

Pick a level by WHO needs the line and HOW OFTEN it fires, not by how interesting it felt while writing it:

levelfiresexample
trace!per ITEM - anything that scales with contentone line per spawned object, per section, per widget
debug!per OPERATION - one line for the whole batchscattered 26 of 26 'gauntlet_rock_' object(s)
info!a person running the game wants it; should be rarethe probe’s own report lines
warn!/error!something is WRONGa scenario names an id that does not resolve

An expected-and-handled condition is not a warning. A batch summary carries a COUNT - a summary line without a number is barely better than the noise it replaced.

RUST_LOG directives are MERGED over this filter by bevy rather than replacing it (bevy_log-0.19.0/src/lib.rs:406), so RUST_LOG=nova=trace restores the per-item detail without unmuting wgpu and naga.

A headless run additionally clamps three bevy diagnostics it provokes by construction - the missing render app, CompressedImageFormatSupport, and gizmos noticing there is no RenderApp. All three are unreachable when a render sub-app exists, so a rendering run keeps those targets at their normal level.

Examples

examples/ exercises one subsystem each, end to end; this repo prefers runnable examples over isolated unit tests. The examples live in purpose directories (bevy-repo style: category dirs, plain slug names), and the [[example]] catalog in the root Cargo.toml (autoexamples = false) is the single source of truth, listed in curriculum reading order.

The category contract

A category is not a folder - it is a promise about WHO an example is for. Pick the category by its audience, not by what it happens to spawn.

CategoryWho it is forWhat probe does with itDisqualifies an example
playable/A HUMAN: somebody loads it and does the thing it demonstrates, through an affordance wired outside the NOVA_AUTOPILOT gateruntime contract decides; native trace is automaticits only affordance is the free-fly camera every cameraless scene already gets
systems/The PROBE: a behavior staged and asserted, every claim named on the invariant rosterruntime contract decides; native trace is automaticits product is a frame for human eyes rather than a verdict
screenshots/The WEBSITE and the wiki: frames, webm loops, posed lineups, the frame-time baselineruntime contract decides; native trace is automaticits verdict is an assert, or a human is meant to drive it

The test between playable/ and the other two: would a human loading this expect to DO something? If the name promises a verb, it owes the verb. The test between systems/ and screenshots/ is what a run PRODUCES - an assertion, or a picture.

There are THREE categories and there is no fourth: an example that does not fit one of them is miscategorised, not a new kind. Autopilot in a playable/ example is a SECOND driver, for captures and for the run gate - never the only one.

Run policy is DECLARED by the example at runtime, through the probe plugins it wires (nova_probe::contract); probe reads it back from probe-contract.json. Every cataloged example is spawned (--all subtracts nothing), and one that declares no capability grades UNPROBEABLE - the sanctioned opt-out, gated on its smoke checks alone. The grading detail lives under Run verification (probe); what each category proves is the table above plus the per-block comments in the root Cargo.toml, and review enforces it.

The catalog and the harness

What is on disk today, in reading order:

  • playable/ - what a person loads and works. The hulls first: carve_asteroids (fly a PDC rig at a row of shipped-size rocks and hole one by hand) and wfc_arena (a match bench for wave-function-collapse hulls, with a lobby, a pause menu, a result board and a --ship TEAM:player slot that puts you in one of them). Then the benches and galleries, each with its own keys: wfc_ships (R re-rolls the collapsed row), shape_bench and block_bench (L cycles the style, C strips the cladding), greeble_catalog (a selection ring, a focus turntable, pedestal and cell-frame toggles), parts_viewer (a paged grid, a focus turntable and a reassembled recipe ship with an explode toggle), widget_zoo (every nova_ui widget factory, live and clickable in both skins) and compare_asteroids / compare_planets (the number keys re-dress the focus subject). All of them still walk and capture under NOVA_AUTOPILOT, which is what keeps them on the probe gate.
  • systems/ - the correctness ranges. Every name carries a prefix for the KIND of check it is - system_ functionality, bug_ a regression range for a defect that was found, stress_ load - and examples/systems/README.md owns the rule. The section curriculum first: system_attitude_hold (PD attitude), system_thrust_and_plume (burn -> thrust + plume shader), system_hull_damage (damage -> destroy -> ship survives, and the mass properties the losses move), system_section_severing (a destroyed interior section leaves a real hole and the structure behind it drifts free as its own wreck body), system_destruction_finale (every destructible body - gltf section, procedural section, multi-part turret, asteroid - breaking into its OWN art rather than generic cubes, on one budget), system_turret_gunnery and system_torpedo_launch (the weapon ranges, the latter also the PN lead-a-crosser deep-dive), and system_blast_penetration (explosive falloff, one section shielding the next and two salvos that cannot use the hole they make in the same tick). Then the cross-cutting systems, every fixture a ScenarioConfig written in Rust and loaded with LoadScenario: system_scenario_grammar (the scenario language - variables, events, filters, actions), system_player_path (a scenario played through the real input pipeline: lock, kill, travel-lock, GOTO), system_outcomes (die -> the Defeat overlay -> Retry -> a clean reload -> kill -> the objective and the CHECKPOINT -> Continue -> the chained scenario), bug_neutralized_quiet (a wreck’s point defence stands down) and system_borrowed_battery (the Flight Computer works idle player PDCs). Then the interface, driven by synthesized pointer input: system_ship_editor (build a ship and inspect it, refusals included), system_field_controls (the inspector’s number rows: the unit a field’s declaration gives it, the step a drag on its name moves it by, and the floor that drag ARRIVES at where a typed value is refused), system_input_modes (one owner of the keyboard at a time - the same key pressed under a text field, the gallery, a pending rebind and none of them, each verdict a NEGATIVE read after a settle because a key that went nowhere raises no event), system_ui_scale (a world-anchored label holds its LOGICAL offset when the scale factor doubles and the window changes shape, and the top bar and the stage’s nameplates are read at every shape alongside it), system_hud_indicators (where a screen-projected indicator lands), system_menu_boot (the shipped boot flow) and bug_menu_picker (the Scenarios picker, whose pane split must not depend on the selection - real fonts, real taffy, which a headless unit rig cannot measure at all), bug_sandbox_soak (the editor sandbox entered and then left alone, holding one physics step to its own timestep), bug_carve_apply (one cut that severs a rock, and what the main thread pays to SWAP the result in - the range counts grids rather than milliseconds, so it reads the same on every box) and system_nova_os (the Tab ship computer, opened with a keystroke and clicked THROUGH the CRT glass, so the whole forwarded-pointer chain - window rect, screen-to-image mapping, offscreen UI stack, Activate - is asserted live rather than one link at a time). Finally the STRESS ranges, one file each: stress_bullets, stress_torpedoes, stress_point_defense, stress_one_structure and stress_many_structures hold a thousand rounds, a thousand guided torpedoes, one battery working a stream of inbound ordnance, a thousand sections on one body and a hundred bodies, each asserting exact counts, a drain to zero and a teardown that leaves nothing, with a frame-time capture riding along. Their scale constants are named and carry the comment that they must NEVER reflect real content; stress_point_defense also reads NOVA_STRESS_PD_MOUNTS and NOVA_STRESS_PD_BAYS, so one build can be swept across scales without moving what it asserts. The UI idiom: a beat NAMES its target (click_named / hover_named / ui_node_centre / ui_node_rect in nova_autopilot::input) so a layout move is survivable and only a rename breaks a run; nothing reaches a widget by triggering its observer or inserting its state component. A target past the fold is SCROLLED to (scroll_lines / scroll_pixels turn the wheel), so a list taller than its box costs the run nothing, and a target behind a render target is AIMED at by undoing the composite that displays it (nova_os_window_px_showing), since a node laid out by an offscreen camera reports a rect in a space no cursor can be placed in. A driven run that still cannot reach a target says so and states its COVERAGE in the verdict - bug_menu_picker names any row it gave up on and fails outright below two measurements, since its property is a comparison across selections. The simulation ranges deliberately do the opposite - their subject is the outcome chain, so pixel coordinates would only add layout coupling.
  • screenshots/ - the content producers, and the NAME says what a run makes: screenshot_* writes STILLS, loop_* writes VIDEO. One producer captures ONE thing in at most THREE frames, because a long scripted walk cannot hold the same result twice - the fat sets these replace strung a dozen captures onto one script, so a beat that drifted took every frame after it with it. Duplication between producers is the accepted price: several stage the same set, and the scene builders they share live in examples/screenshots/shared/. The stills, by set: the Drydock drift beauty shots (screenshot_gravity, screenshot_hero_ship), the menu and editor walks (screenshot_menu, screenshot_scenario_picker, screenshot_editor), the section closeups (screenshot_section_frame, screenshot_section_weapons), the Tab ship computer (screenshot_nova_os_terminal, screenshot_nova_os_apps), the Rock hollow combat beats (screenshot_radar_lock, screenshot_contextual_hud, screenshot_combat_lock, screenshot_combat_hud, screenshot_combat_wide, screenshot_hull_juice, screenshot_torpedo_run) and the flight computer around a real well (screenshot_orbit, screenshot_goto_burn, screenshot_flip_burn). The two webm producers are loop_torpedo_blast and loop_spine_cut. The posed LINEUPS live here too - screenshot_thruster_gallery (the shipped drive, the proposed shell family and the CC0 candidates in one named row) and screenshot_damage_levels (the same ship at five damage levels, side by side, which is one comparison and so one producer). Neither registers a key, so a hand-run is a free-fly look at a still row; either would move to playable/ the day it grows greeble_catalog’s selection layer. scripts/gen-web-screenshots.py --producers prints the list the site actually consumes, so a capture flow never runs off a hand-kept array.

When adding a substantial feature, add or extend the range that drives it. When fixing a bug, WRITE the range that reproduces it first: that is the doctrine in AGENTS.md, and the invariant roster is what keeps it honest.

Every example is HARNESSED: it drives itself under NOVA_AUTOPILOT=1, and probe is the regression suite over all of them - cargo run --features debug probe run systems (or screenshots, or playable) runs one category alone, and --all is the whole catalog, which is what CI runs. Each example must reach Playing and exit without panic; every systems/ range additionally carries panic-on-failure behavior assertions with completion backstops (a stalled script fails instead of passing vacuously). The rosters are pinned by the display-free systems_ranges_assert_their_invariant_roster, so an invariant cannot be deleted into a still-green run. The screenshots/ and playable/ examples carry no behavior assertions of their own - they drive the shipped scenes to capture frames - but every one walks an AutopilotPlugin step timeline, so a beat that never resolves is an error exit naming that step, and every one adds nova_probe::NovaProbePlugin, so a probe run grades the walk on the engine invariants. Disk and catalog cannot drift: the display-free catalog_matches_disk test (crates/nova_probe_cli/tests/catalog_drift.rs) fails cargo test --workspace when a new example misses its [[example]] block. That is the case nothing else catches - with auto-discovery off, an uncataloged example file does not build at all and no other tool says so.

The drivers themselves - AutopilotPlugin, the screenshot capture, the completion protocol, and the full NOVA_* environment contract - live in the nova_autopilot crate and are documented on The automation harness. This page only shows the run recipes; that page is the contract.

Harness runs are SILENT: any harness env (NOVA_AUTOPILOT, NOVA_CAPTURE) zeroes the audio output via HarnessMute - Xvfb hides the window but not the speakers, and nobody listens to a scripted run. The volume SETTING is untouched (persistence and the settings menu never see the mute). NOVA_MUTE=0 forces sound through a harness run; NOVA_MUTE=1 mutes a normal one, and the game binary’s --mute flag does the same - see the outputs-off pair above.

Examples as bug pins

A bug becomes a RANGE (AGENTS.md): reproduce it in examples/systems/ before the fix, and the fix is what turns the range green. A unit/App test still pins a system-level mechanism, but anything that only manifests in a composed scene belongs in a range (for example, system_menu_boot runs the shipped boot flow with the ECS fallback error handler swapped to panic, so unhandled command errors on those transitions fail CI). A range’s pin is an autopilot-script assertion (a named step whose on_enter asserts, reached only once the steps before it have waited on the world - see system_hull_damage/system_hud_indicators for the style) carrying an outcome: <slug> marker on the roster; CI’s probe sweep runs it on every push. Caveat: the handler swap does NOT catch remove/despawn command warns (they bake in the WARN handler at queue time).

Launching a scenario from the command line

--scenario <id> on the game binary boots straight into that scenario, past the main menu - for anyone who would rather not click through the picker, and for a mod author testing one scenario id in one command:

cargo run -- --scenario broadside          # a base scenario
cargo run -- --scenario my_mod_intro       # anything an ENABLED mod registers
cargo run -- --scenario nope               # refuses, and lists every id
  • The id is matched against the MERGED registry (base plus every enabled mod), so a mod’s scenarios are launchable by id and hidden chapters and menu backdrops are too - a superset of the rows the Scenarios picker shows.
  • An unknown id prints the full id list to stderr and exits non-zero. The check happens once the content merge has run, which is after the window opens: the merged registry does not exist before that.
  • The launch is the picker’s own door (NewGameScenario + GameMode::NewGame
    • GameStates::Playing), so the scenario comes up through the same loader and the same non-blocking load screen as a click on Play.
  • Native only. The wasm bundle has no command line.

Content CLI

content (nova_authoring::cli::main, crates/nova_authoring/src/cli.rs; a subcommand of the game binary, not a separate bin) authors and validates the game’s content. Two subcommands, run from the repo root:

cargo run content gen                                   # regenerate the base *.content.ron
cargo run content lint                                  # lint the whole content tree
cargo run content lint --target <mod>                   # lint one mod (dir, id, or `base`)
cargo run content lint --target <mod> --report r.md     # + write a per-mod report (md|html)
  • gen serializes the code-built base content into the committed assets/base/**/*.content.ron. The base RON is GENERATED from Rust builders (nova_authoring::generation, backed by private base_content) - edit the builder and regenerate, never hand-edit the RON, or the content_ron_parity test goes red.
  • lint runs EVERY content check in one pass (the audit subcommand was folded in here - balance is a kind of lint):
    • the identifier + geometry + resource checks the load/publish gates cannot (dangling NextScenario targets, unspawnable filter targets, duplicate ids, scenarios with no terminal Outcome, resource-ref membership, …);
    • the combat balance/fairness audit - every combat scenario’s derived sheet, graded for spawned-dead (ERROR) and close-spawn (WARN) hostiles; a bundle acknowledges its OWN deliberate imbalances in a balance_acks.ron beside its manifest, so the justification travels with the mod (a stale ack that matches no live finding is an ERROR, so every list stays pruned);
    • the flight-rig input-overlap check - a content input_mapping section bound to a key the always-on flight rig also binds (W/Space/RightTrigger burn, autopilot, …) silently double-drives flight and is flagged (WARN).
    • --target lints a single mod by directory or in-repo id (webmods/<id>, assets/mods/<id>, or base); --report <path> writes a per-mod document (Markdown, or HTML for a .html path / --format html) that names, for each finding, the file + element + explanation + suggested fix. Exits non-zero on any ERROR. The content_lint_gate, balance_audit_gate and content_report_gate tests run these walks in CI.

Web build

WASM via Trunk (Trunk.toml, index.html):

trunk serve            # serve the game alone on http://localhost:8080
trunk build --release

For the full site (game at /play/, mod portal at /mods/) with everything watched, use scripts/serve-web.sh - see Local web preview below.

.cargo/config.toml sets --cfg=web_sys_unstable_apis for wasm; bevy_rand uses its wasm_js feature there. Trunk only supports the release profile. The GitHub Pages deploy (.github/workflows/deploy-page.yaml) builds the landing site (web/) at the root, the game under /play/, and the generated mod portal (scripts/gen-portal.py) under /mods/.

The same sources fan out into three build targets that combine into one published site:

flowchart LR
  src[Sources]
  src -->|cargo| native[Native game]
  src -->|web build| landing[Landing + wiki]
  src -->|trunk| wasm[Bevy WASM game]
  landing --> pages[GitHub Pages]
  wasm --> pages
  pages --> root["/ (landing)"]
  pages --> play["/play/ (game)"]

Local web preview

The published site is three builds stitched together - the content site at /, the WASM game at /play/, the generated mod portal at /mods/. Serving only one of them locally is what makes Play fall back to the landing page and the in-game Explore tab come up empty. Two scripts cover the two things you actually want:

nix develop -c scripts/serve-web.sh      # live dev: all three, watched
nix develop -c scripts/preview-web.sh    # one-shot static build of the deploy

serve-web.sh starts all three servers and proxies the other two onto the site’s origin, so a single URL has the deployed shape:

flowchart LR
  you([Browser])
  you -->|":UI_PORT/"| site["webpack dev server<br/>watches web/src"]
  site -->|proxy /play| game["trunk serve<br/>watches crates, src, assets"]
  site -->|proxy /mods| mods["serve-mods.sh<br/>watches webmods/"]

Everything rebuilds on save: edit a wiki page and the tab reloads, edit a crate and Trunk rebuilds the wasm, edit a mod and the portal is regenerated in place. --release switches the game to an optimized build. Ctrl-C stops all three.

Each server takes a random free port in 7000-7999, so several worktrees can serve at once - the banner prints the URLs. Pin any of them, or point the site at servers you started yourself:

VariableRead byEffect
NOVA_UI_PORTweb/webpack.config.jsFixes the site’s port.
NOVA_GAME_PORTscripts/serve-web.shFixes the game’s port (exported as TRUNK_SERVE_PORT).
NOVA_MODS_PORTscripts/serve-mods.shFixes the portal’s port.
GAME_DEV_URLweb/webpack.config.jsWhere /play is proxied. Default http://localhost:8080 (Trunk’s own default).
MODS_DEV_URLweb/webpack.config.jsWhere /mods is proxied. Default http://localhost:9000.

Two things are worth knowing before you go off-script:

  • Trunk needs explicit watch paths here. Its default (“the build target’s parent folder”, i.e. the repo root) never fires in this repo, so a bare trunk serve keeps serving the first build no matter what you edit. serve-web.sh passes --watch for each real input (crates, src, assets, credits, build, index.html, Cargo.toml, Cargo.lock).
  • The portal must be same-origin with the game. The wasm build derives its portal base from window.location, so under /play/ it fetches <origin>/mods. That is why the site server proxies /mods, and why a cross-origin ?portal= override fails on CORS. See Publish a mod.

preview-web.sh is the other half: no dev servers and no proxies, just trunk build + npm run build + gen-portal.py assembled into web/dist and served statically on :8090. It does not watch anything, but it is the only local check of the real deploy layout - run it before a release.

Regenerating the web screenshots

The site’s .figure blocks ship as placeholders; the real screenshots are captured in-engine and packaged into web/src/assets/ by scripts/gen-web-screenshots.py. Each figure auto-upgrades to its image at runtime once the asset exists (progressive enhancement in web/src/site.ts), so no HTML edit is needed - just drop the file in.

Capture (needs a display + GPU; headless CI-style is Xvfb + lavapipe) into a staging dir, then package into web/src/assets/:

export NOVA_CAPTURE_DIR=target/shots
for shot in $(python3 scripts/gen-web-screenshots.py --producers); do
    NOVA_AUTOPILOT=1 NOVA_CAPTURE=1 cargo run --example "$shot" --features debug
done
python3 scripts/gen-web-screenshots.py   # validate + copy; build composites; write the 44x44 icons

The capture examples run headless under NOVA_AUTOPILOT: each is one autopilot script whose steps pose the camera and shoot, and NOVA_CAPTURE is what makes those shot steps write 1920x1080 PNGs rather than drive straight through. The Python step validates each shot is 16:9, copies it in, builds the composite shots a single capture cannot make (e.g. devlog5-radar-stance-slots, two lock stances side by side) with a stdlib PNG codec, generates the section icons, and reports which shots have no capture example yet. Commit the resulting PNGs (they are content, like banner.png). Run python3 scripts/gen-web-screenshots.py --self-test to check the PNG codec (decode/resize/compose) and the report’s classification rules in isolation.

What is still missing

python3 scripts/gen-web-screenshots.py --report

Scans web/src/** for referenced assets/<name> images, diffs them against the manifest and the shipped assets, and prints each gap with an owner class:

ClassMeaning
capturableA game render a cataloged example can capture.
manualAuthored art (post-card thumbnails, icons, diagrams) - no automation produces it.
historicalA figure for an older shipped version; the current build can only approximate it.

Wrong-shaped and unreadable assets, assets the site never references, and staged PNGs the manifest does not declare print the same way. The report is ADVISORY: it copies nothing and always exits 0, so it is a worklist (for the owner: what art to draw; for automation: what a producer could capture), never a gate.

It closes with the GAME’s half of the same worklist: every Scenarios-picker thumbnail still on generated placeholder art, classed manual.

Scenario picker thumbnails

Every picker-visible scenario shows its own image in the details pane. Real per-scenario art is authored, not captured, so until it exists each scenario carries a deterministic placeholder - a 320x180 phosphor plate of its own title:

python3 scripts/gen-scenario-thumbnails.py           # write every PNG
python3 scripts/gen-scenario-thumbnails.py --check   # verify, write nothing

The PNG lands in the OWNING mod’s tree (assets/base/thumbnails/<id>.png, webmods/<mod>/thumbnails/<id>.png) and is referenced as self://thumbnails/<id>.png - never dep:// another mod’s art. Drop real art at the same path and nothing else changes; the report stops listing it, because the file no longer matches a fresh render. A new scenario adds one row to SCENARIOS in the script and one entry to its bundle’s resources; a scenario with no art of its own is what the coverage report lists.

Greeble meshes

The decorative fixtures a hull skin bolts onto its plates - vents, ribbing, blisters, masts - are GENERATED, not modelled. scripts/gen-greebles.py builds one .glb per JSON recipe in scripts/greeble-recipes/ into assets/base/gltf/greebles/, all committed:

python3 scripts/gen-greebles.py             # write every .glb
python3 scripts/gen-greebles.py --check     # verify, write nothing
python3 scripts/gen-greebles.py --self-test # internal checks, no I/O

A recipe is a palette plus a list of primitives - box, cylinder, taper, ribs, disc - each with a size, an at offset and an optional rotate. Solids compose by overlap, not by CSG. Adding a piece is a new recipe file plus one entry in assets/base/base.bundle.ron’s resources; no code changes.

Pieces are authored in the PLATE’s frame (the unit cell, out along +Y, y = 0 the mounting face) and the generator refuses anything behind that plane, wider than half a cell, taller than its declared budget, or over 200 triangles. The build is byte-deterministic, which is what --check gates: generated art that churns turns every unrelated diff into a binary one.

The three mesh scripts (gen-greebles.py, cut-obj-into-hulls.py, cut-obj-into-parts.py) share ONE hand-rolled, stdlib-only glTF writer, scripts/nova_glb.py. Prove a change to it kept the committed art intact by re-cutting a ship and diffing against assets/base/gltf/parts/<ship>/, which is byte-reproducible from its recipe.

Eyeballing the site

npm run ci proves the bundle compiles and the theme tokens are in sync; it proves nothing about how a page LOOKS. For any styling, layout or readability change, capture the pages and look at them:

nix develop -c scripts/shoot-web-pages.sh target/web-shots

It builds web/, serves web/dist on a free port and drives headless chromium over the six page kinds (landing, news index, a news post, tutorial, wiki index, a wiki page with code + a table + mermaid) at desktop and mobile widths, writing <kind>-<width>.png plus a manifest.txt naming the commit. For a before/after, run it once per commit into two dirs and compare the matching pairs at identical crop and scale - a comparison at two different zooms shows you the resize, not the change.

The theme is shared with the game

web/src/style.css and crates/nova_ui/src/theme.rs both mirror the :root block of web/design/nova_ui_rework_poc.html - the NOVA OS palette and its control vocabulary. That PoC is the single source: change it first, then both consumers.

The PoC ships two skins and the site wears only one. Everything the site draws comes from the PHOSPHOR skin (the PoC’s body[data-skin="phosphor"] widget zoo): flat translucent green fills, 1px phosphor hairlines, 2px corners on controls, glow instead of bevel, and a solid --phosphor inversion for the primary state. The light-3D HARDWARE vocabulary (--face, --rim, --undercut, --well) stays in :root only to keep the mirror exact - it must be consumed nowhere.

web/tests/theme.test.ts (part of npm test, and so of npm run ci) parses the PoC and style.css and fails if the site’s tokens go missing or drift in value, if the phosphor vocabulary stops being consumed, or if any hardware material token is read outside :root.

Run verification (probe)

The run-harness is two crates split at the process boundary: nova_probe (crates/nova_probe/) links into the example and collects the evidence, and nova_probe_cli (crates/nova_probe_cli/) is the host side. Together they drive an autopilot example, records what happened (correctness) and what it cost (performance), and assembles one reviewable report. The POST-FEATURE CHECK - “did my change break behavior or perf?” - is one command:

cargo run --features debug probe run system_player_path            # clean + frame time + trace -> report
cargo run --features debug probe run system_player_path --correctness-only # clean behavioral evidence only
cargo run --features debug probe run system_player_path --samply   # + named flamegraph
cargo run --features debug probe run system_player_path --baseline probe-runs  # FPS deltas vs nearest prior commit
cargo run --features debug probe run system_player_path --repeat 5  # gated repeat set -> a usable worst frame
cargo run --features debug probe run system_player_path,system_scenario_grammar   # comma list -> aggregate index
cargo run --features debug probe run systems            # a whole category
cargo run --features debug probe run --all               # the whole fleet
cargo run --features debug probe scenario broadside      # a SCENARIO by id, no example involved
cargo run --features debug probe scenario assets/base/scenarios/broadside.content.ron  # ... or by file

It runs the example headless (throwaway Xvfb; --display :0 to reuse yours - and note that a software X server is not free, see Measuring performance), captures the run timeline + continuous invariants + the log into probe-runs/<short-commit>/<example>/ by default (or <out-base>/<short-commit>/<example>/ with --out <out-base>), optionally adds the profiled and samply passes (separate builds - tracing overhead never touches the clean numbers), and renders report.html + checks.json with a provisional OK/WARN/FAIL/NO_DATA/UNPROBEABLE the reviewer confirms. Every run dir carries a probe-run.json manifest (identity, full git SHA, passes, outcomes); probe report only re-renders dirs that have one. The commit root also gets index.html, index.json, and probe-all.json, even when the spec names one example. --correctness-only runs only the clean pass: timeline, invariants, autopilot assertions, completion, reached-Playing, and log checks remain armed, while frame-time and traced passes are omitted. CI uses this mode; release verification uses the full run. Three verbs are the whole surface - run, scenario and report - and each takes -h/--help, as does the root.

probe scenario takes those same passes to a scenario, with nothing in between: the SHIPPED GAME BINARY is the program, launched with --scenario <id> or --scenario-file <path.ron> and carrying the probe collectors under its debug feature. A positional ending in .ron is a loose content file, registered for that run whether or not it is installed - so a scenario a contributor or a mod author is still writing is measurable without adding it to any catalog. Its self:// art resolves against the nearest enclosing *.bundle.ron folder, the same place the merge would have resolved it. There is no spec to expand and no aggregate: one subject, one run dir, the same report.html + checks.json. Measuring a scenario needs NO example, no [[example]] block and no Rust file - that is the point of the verb.

Every run spec resolves to a list. A single example is just a one-item list; comma lists, category dir names, and --all expand against the [[example]] catalog and run sequentially with continue-on-failure. The status index lives above the example dirs: index.html (one row per example - verdict, measured n/total, one column per check, duration, a link to its report), index.json (the machine mirror), and probe-all.json (the re-render gate). The aggregate verdict is the WORST row; the exit code mirrors it. --all runs the whole catalog, and a bare probe run errors with the catalog listing rather than starting a fleet sweep by accident. Categories take single-digit minutes warm; --all is the pre-release/nightly sweep (roughly half an hour). --baseline <base> searches <base> for the nearest previous commit-hash directory in git history, ignoring compatibility folders such as before, then each example compares against <base>/<previous-short-commit>/<example>/frametime.csv when present. Without --baseline, probe searches the same base used by --out, defaulting to probe-runs.

Probe runs are profile-sandboxed: a run measures a commit, so it must not depend on your desktop profile. Every native child run is pointed at an empty, probe-owned profile under its own run dir - profile/mods (NOVA_MODDING_CACHE_ROOT, the downloaded-mod cache and its installed.mods.ron), profile/data (XDG_DATA_HOME) and profile/config (XDG_CONFIG_HOME, where enabled_mods.ron and settings.ron live) - and the tree is wiped at the start of each run. Without it, a mod cached in a structure an older commit cannot parse, or a saved enabled-mod set, fails or shifts a run for reasons unrelated to the code under measurement. Shipped content is untouched: assets/ and assets/mods.catalog.ron load exactly as they do for a player, only YOUR saved state is swapped out. To probe your real installed mods, export the variable yourself - probe preserves any of the three it finds already set, and prints which ones it left alone:

NOVA_MODDING_CACHE_ROOT=~/.local/share/nova-protocol cargo run --features debug probe run system_player_path

XDG_CACHE_HOME is deliberately NOT redirected (the shader cache lives there, and throwing it away each run would make FPS numbers incomparable). The XDG pair is how the dirs crate resolves on Linux, the supported probe host; nova_probe_cli::native::profile_sandbox has the details.

Frame-time capture, the repeat set and its validity gate, the fixed-step and Xvfb traps, the frame-cost and census breakdowns, the preset sweep and the profiled pass all live on Measuring performance. This section stops at the run and its verdict.

Run timeline (correctness recording)

nova_probe also records WHAT HAPPENED during a run: set NOVA_PROBE_TIMELINE=<out.jsonl> on any example that adds nova_probe::nova_timeline() - which NovaProbePlugin does unconditionally, so that is EVERY cataloged example - and the run appends one JSON object per line: every GameStates/pause transition, every fired scenario event with its payload (kills, area enter/exit, locks), every scenario-variable change (old/new), plus the beats the autopilot script pushes itself via nova_probe::probe_marker. Entries are flushed as written, so a panicked run keeps everything up to the panic. Compare runs by ORDER and VALUES, not timestamps (wall-clock and frame counts vary across hosts):

NOVA_PROBE_TIMELINE=/tmp/run.jsonl NOVA_AUTOPILOT=1 \
  cargo run --example system_player_path --features debug

The timeline is native-only (no fs in the browser) and inert without the env var. It is the correctness half of the run-harness whose performance half is Measuring performance; the run report below renders both.

Continuous INVARIANTS ride the same stream: set NOVA_PROBE_INVARIANTS=1 (or =strict to panic on the first violation) on a wired example and every frame asserts what the engine guarantees - health within 0..=max and finite, velocities finite (plus an absurd-speed bound at 10x a ship’s soft FlightSpeedCap), scenario Number variables finite, registered monotonic variables never decreasing (opt-in per example: system_player_path registers target_down/leg, system_scenario_grammar seven counters and latches, system_outcomes hostile_down), and a total entity-count leak bound. A monotonic is one-way within a SCENARIO LIFE, not for the process: the memory is forgotten on ScenarioLoaded, so an example that replays through its loop point re-seeds its latches without taking a false regression. Violations warn, land on the timeline as kind: "invariant" entries, and feed the report’s invariants held check.

The run report (one verdict surface)

run_report assembles a RUN DIRECTORY - whatever the passes above dropped into it (timeline.jsonl, frametime.csv, trace.json, run.log, each optional) - into a self-contained report.html plus a machine-readable checks.json:

cargo run --features debug probe report <run-dir>... [--baseline <old-run-dir>]

Auto checks produce a provisional OK/WARN/FAIL/NO_DATA/UNPROBEABLE (process exit from the run manifest, run completed, reached Playing, invariants held, FPS vs baseline as a soft gate, log scan, artifacts loadable); a check whose capability the example never declared is N/A - “not claimed” - and an unresolvable one is SKIPPED - “not measured”; neither means “held”. checks.json pairs the verdict with a measured: n/total figure plus per-check structured data. A present-but-unloadable artifact degrades that one artifact to absent and FAILS artifacts_loadable with the reason, rather than aborting the report the failure would have been visible in. Zero evidence is NO_DATA (nonzero exit) and a run that graded no declared capability is UNPROBEABLE (zero exit - the sanctioned no-probe-plugin opt-out, gated on its smoke checks alone), FPS improvements PASS (only regressions WARN - frame numbers are host-noisy), a hung run is killed and still produces a FAILing report, and the report ends with a reviewer checklist: the final OK/NOT-OK is a human’s or an agent’s call, off checks.json without parsing HTML.

Versioning and release

  • Version: workspace.package.version in root Cargo.toml; crates inherit it.
  • nova_info::APP_VERSION comes from the APP_VERSION env var via build.rs.
  • Packaging assets (icons, installer, .app) live under build/.

The nix package

The flake packages the game as well as the development shell, so a player needs neither a checkout nor a toolchain:

nix run github:alexjercan/nova-protocol         # build it and play
nix profile install github:alexjercan/nova-protocol
nix build .#default                             # ./result/bin/nova-protocol

Three outputs, and which one you want depends on what you are doing:

outputwhat it is
packages.defaultthe wrapped game - the one to install
packages.nova-protocolthe same derivation, under its own name
packages.nova-protocol-unwrappedthe bare cargo binary

The unwrapped binary finds no assets and opens no window on its own. It is split out so that editing the desktop entry, the icon or the asset wiring costs a second instead of a fat-LTO relink of the whole Bevy graph. The wrapper is what makes it a game:

  • assets/ and credits/ are installed under share/nova-protocol, and BEVY_ASSET_ROOT points bevy’s reader at them. Without it the reader falls back to the directory the executable sits in, which in the store holds the binary and nothing else. It is set as a DEFAULT, so a modder can still point a packaged build at a working tree.
  • The libraries Bevy opens with dlopen - vulkan, wayland, X11, xkbcommon, alsa, udev - go on LD_LIBRARY_PATH. The linker never records them, so a packaged binary finds none of them without this. It is the same list the development shell exports, shared in flake.nix rather than written twice.

The package builds with --profile dist, the profile .github/workflows/release.yaml ships, so nix run gets the binary a player gets. Fat LTO and one codegen unit: budget half an hour for a cold build.

webmods/ is NOT in the package. Those are the portal’s development fixtures, served by scripts/serve-mods.sh; the mods:// source reads installed mods out of the player’s data directory (~/.local/share/nova-protocol/mods), which no store path can hold.

The package’s toolchain is rust-project.toolchain, set to the same rustNightly value the development shell uses. Left alone, rust-flake resolves its own from rust-toolchain.toml, and the package and the shell could drift apart on a nix flake update - so there is still ONE pin in flake.nix, the one the comment beside it names.

The desktop entry

nix profile install also installs share/applications/nova-protocol.desktop and the icon into the hicolor theme, so rofi -show drun, wofi, and the GTK and KDE menus list “Nova Protocol” with its mark. ~/.nix-profile/share is already on XDG_DATA_DIRS, so nothing else has to be wired; nix run and nix build do not install it, because neither adds anything to a profile.

The icon is rendered from web/src/favicon.svg, the site’s brand mark, into the eight hicolor sizes plus the scalable SVG. A launcher resolves Icon=nova-protocol by name against the theme, so the sizes have to exist as files: an icon theme with no scalable support finds nothing otherwise. The art under build/ is still bevy_game_template’s placeholder bird and is deliberately not used.

Cutting a release

Pushing a tag v[0-9]+.[0-9]+.[0-9]+* triggers release-flow (.github/workflows/release.yaml). Steps, on master:

  1. Check the documentation surfaces are current (see Keeping docs in sync): this book builds clean (mdbook build) and the pages the cycle’s changes touched are updated.
  2. Bump workspace.package.version in root Cargo.toml.
  3. Refresh Cargo.lock: cargo metadata --format-version 1 >/dev/null.
  4. Update CHANGELOG.md (Keep a Changelog, one concise line per entry): promote [Unreleased] to [<version>] - <YYYY-MM-DD>, leave a fresh empty ## [Unreleased] on top, merge any duplicate section headings that grew during the cycle, and update the compare links at the bottom (repoint [unreleased], add the new [<version>] line).
  5. Commit exactly those three files: git add Cargo.toml Cargo.lock CHANGELOG.md && git commit -m "chore(release): vX.Y.Z".
  6. git tag vX.Y.Z (CI reads the tag for the release name).
  7. git push origin master && git push origin vX.Y.Z.
  8. Watch the run (gh run watch), then check the GitHub release page and consider adding summarized release notes (gh release edit vX.Y.Z --notes-file ...).
  9. Write or expand the release News post (see “Writing the release news post” below) and land it in web/; sync any wiki pages the cycle changed (see Keeping docs in sync).

The workflow uploads four assets to a release named after the tag: macOS universal .dmg, Linux .tar.gz, Windows .zip, and a wasm-opt’d web zip. It can also be re-run via workflow_dispatch with a version input.

Writing the release news post

Every release cycle gets one News post on the site (/news/, markdown under web/src/news/). News is the merged devlog + release notes: one post per FEATURE release (v0.X.0). Patch releases do NOT get their own post - they fold into the parent feature post’s ## Point releases section (v0.5.0’s post covers v0.5.1 and v0.5.2). The terse per-version list stays in CHANGELOG.md; source the post’s content from the cycle’s CHANGELOG.md sections.

A News post follows the spirit of Factorio’s Friday Facts: a narrative lead, then a handful of feature-by-feature ## sections written candidly (the reasoning, the dead-ends, the piece you are proudest of), leaning on screenshots, and - where a devlog video exists - an optional ## Watch the devlog companion near the top (the written highlights must stand on their own; the video is an extra). Do not just restate the terse CHANGELOG.md.

Adding a post touches three places (mirror an existing post such as web/src/news/0.5.0.md):

  1. Write the post at web/src/news/<version>.md (e.g. 0.6.0.md). The page shell (newsPostShell in web/markdown.js) renders the H1, the <date> // v<version> meta line, and the footer (the Discussions prompt plus the CHANGELOG.md pointer and “All news” link), so the markdown is just the body: the H1 (# vX.Y.0 - <title>), the lead, the ## sections, .figure placeholder blocks for screenshots to capture later, an optional .video-embed companion, a .callout.callout--breaking block for any format break, and a closing ## Point releases section for the cycle’s patches. Do not add a footer or a CHANGELOG.md link yourself - the shell adds them.
  2. Register it in web/webpack.config.js: add an entry to NEWS_POSTS (newest-first) with slug/version/date/description. The plugin list and the historyApiFallback rewrite both derive from NEWS_POSTS, so no other wiring is needed.
  3. Add a .post-card to web/src/news.html at the top of .post-grid (newest-first): a media thumbnail plus the date/version, title, and one-line excerpt. For the thumbnail, use the YouTube thumbnail (https://img.youtube.com/vi/<id>/hqdefault.jpg) if the release has a video, otherwise the .post-card__ph placeholder naming assets/thumb-news-<version>.png.
  4. Rebuild and check it: cd web && npm run ci (format check, lint, test, build).

Contributing a change

The everyday loop for landing a change:

  1. Branch off master. Work items are tracked as tasks/ markdown (see Task tracking below); check the backlog first.

  2. Build and format: cargo check && cargo fmt before you commit. Do NOT run cargo test or cargo clippy locally unless asked - they are slow and CI is the source of truth; when you skip them, say so.

  3. Drive it with an example. For a substantial feature, add or extend the examples/ example that exercises it, with a harnessed autopilot assertion (see Examples) - this repo prefers a runnable example over an isolated unit test.

  4. Open a PR. CI (.github/workflows/ci.yaml) runs on every PR and push to master: cargo fmt --check, cargo clippy --workspace --all-targets --features debug -- -D warnings, cargo test --workspace --features debug, then the windowed probe run --all --correctness-only sweep under Xvfb/lavapipe plus the nova_autopilot example test under Xvfb. Three more jobs run in parallel with that one: a default-features cargo check --workspace --all-targets under RUSTFLAGS=-D warnings, a wasm32 cargo clippy --workspace --exclude nova_probe_cli (the host harness has no meaning in a browser), and a dependency-license gate. Those two exist to catch dead code and unused imports that only appear with debug off or on wasm - neither configuration is otherwise built. All of it must be green to merge.

    The wasm job is CLIPPY rather than check, and it points CLIPPY_CONF_DIR at ci/wasm-clippy/, whose clippy.toml bans the std APIs that COMPILE for wasm32 and then panic in the browser - a class of bug no cargo check on that target can see. Those bans are correct only there: natively bevy::platform::time re-exports std’s clock, so the same list would flag correct code in the main job. That is why the config lives outside the repository root, and why there is no root clippy.toml.

House style is in AGENTS.md at the repo root - Rust, Bevy, Nova, comments, documentation, changelog and web, each a section. Commit messages are plain and use ASCII punctuation only. Releases are a separate, tagged flow (see Cutting a release).

Task tracking

Work items live as markdown under tasks/ (managed with the tatr CLI), so they are versioned alongside the code. Check the backlog before starting and close tasks when done. Each task has its own folder holding its TASK.md plus any task-scoped records (SPIKE.md, REVIEW.md, RETRO.md, NOTES.md). Multi-task plans are tatr tasks too - a release plan is a task with the strand breakdown in its body (or a release/meta tracker task linking the per-strand tasks). docs/ is the source of this book, not scratch: transient working files live outside the repo, and task-scoped records live in tasks/<id>/.

Project tour

Start here. New to the codebase? Read this book in this order:

  1. Project tour – this page: the crate map and where to change X.
  2. Architecture – the full crate graph, app assembly, state machines and frame flow.
  3. Building & running – toolchain, cargo commands, examples, the web build, and how to contribute a change.
  4. Then pick the guide for your change: Add a ship section or Extend the scenario engine.

This page only orients you: the crate map and where to change X. For exact symbols per concept, use the Concept index.

Nova Protocol is a 3D space game built on Bevy 0.19 with avian3d physics. You build ships out of modular sections (hull, controller, thruster, turret, torpedo bay), fly them with real Newtonian thrust and a diegetic GOTO/ORBIT/ STOP autopilot, work inverse-square gravity wells, and fight with deliberate angular radar lock-on. On top of the game sits an event-driven scenario/modding engine (RON data) and a web site + WASM build. It is a Cargo workspace: the root nova-protocol crate is a thin shell; all the real code lives under crates/.

Crate map at a glance

Slugs are the workspace members. One line each – see Architecture for responsibilities and the dependency graph.

CrateOwns
nova-protocol (root)src/main.rs clap CLI + entrypoint; src/lib.rs re-exports nova_core.
nova_coreWiring only: AppBuilder assembles the whole plugin stack. No gameplay.
nova_gameplayThe shared gameplay layer under the ship: integrity, damage, gravity, the SFX engine, juice, objectives, mesh/transform rigs, entity markers. Owns GameStates/PauseStates/GameMode.
nova_shipThe ship and how it is flown: sections, input (player/ai/radar), flight and its autopilot verbs, the camera rigs, the PD controller, the ship’s soundtrack.
nova_hudThe flight HUD: one module per widget (crosshairs, target inset, ammo readout, objective markers, comms panel, keybind dock). Reads the ship, never drives it.
nova_osNOVA OS logic: the terminal model, shell grammar and app runtime. No bevy UI.
nova_os_uiThe NOVA OS cockpit monitor the player opens with Tab: CRT terminal UI, forwarded pointer, and the map/ship apps. A peer of the HUD, added by nova_core.
nova_scenarioScenario engine: events, filters, actions, variables, world, loader, objects.
nova_eventsShared game-event kinds + entity identity components (gameplay <-> scenario).
nova_assetsbevy_asset_loader setup; loads glb/textures/shaders/sounds; owns the mod merge + prefs.
nova_moddingBundle/content/catalog asset loaders and the Content routing enum.
nova_mod_formatPure serde types for the mod formats (engine-free); re-exported by nova_modding. The static mod portal is built by scripts/gen-portal.py.
nova_editorThe ship editor scene (NovaEditorPlugin), shown in GameMode::Sandbox.
nova_menuMain menu + the ESC pause overlay; hands off to Playing.
nova_inputThe bindings registry: the one table of named actions and the sources each holds, the shared rebind capture, and the by-name dispatch. A leaf under every rig and every rebind surface.
nova_uiShared theme, skin, themed widgets, screen composition, unit formatting. A leaf: every UI-drawing crate (nova_gameplay, nova_hud, nova_os_ui, nova_menu, nova_editor, nova_assets) draws from it.
nova_debugDebug-only plugin (inspector, overlays); compiled under the debug feature.
nova_infoExposes APP_VERSION, injected by build.rs.
nova_autopilotScripted automation drivers + the run-completion protocol. Bevy-only, game-agnostic.
nova_probeDev tool (not in the shipped game): the in-game half of the run-harness - the capability plugins an example wires (frame time, timeline, invariants, world snapshot, scene census, frame cost).
nova_probe_cliDev tool: the host half - spawns runs, grades artifacts, renders reports; the probe run/report CLI.
nova_perf_webDev tool: the wasm app probe run --platform web boots and measures.
nova_authoringOffline content pipeline: the Rust builders for built-in scenarios/sections, content -- gen (writes assets/base/**/*.content.ron), content -- lint.
nova_meta_genBinary under tools/ (web-build tooling, not a game crate): writes default .meta sidecars for web assets (Trunk post_build hook).

Want to change X? Start here

The highest-value table. Verified paths; follow the linked page for depth.

I want to change…Start inRead
A ship section behaviorcrates/nova_ship/src/sections/Ship sections, Add a ship section
Damage types / how a round travelscrates/nova_gameplay/src/damage.rsShip sections
Integrity (disable/destroy)crates/nova_gameplay/src/integrity/Ship sections
How a body wears its damagecrates/nova_ship/src/sections/damage_*.rs + crates/nova_gameplay/src/integrity/{erosion,carve}.rsShip sections
How an asteroid carves, and what it costscrates/nova_scenario/src/objects/asteroid_carve.rsScenario engine
Flight / autopilot verbscrates/nova_ship/src/flight/
Player input / AIcrates/nova_ship/src/input/{player,ai}/
Radar targeting / lock-oncrates/nova_ship/src/input/targeting/
Gravity wellscrates/nova_gameplay/src/gravity.rs
The HUD (widgets)crates/nova_hud/src/
The NOVA OS monitor / its appscrates/nova_os_ui/src/
A scenario event/filter/actioncrates/nova_scenario/src/{events.rs,filters.rs,actions/}Scenario engine, Extend the scenario engine
Scenario objects / loadingcrates/nova_scenario/src/{objects/,loader/}Scenario engine
Mod loading / mergecrates/nova_assets/ + crates/nova_modding/Mod files, Publish a mod
A built-in scenario or sectioncrates/nova_authoring/src/ (builders), then content -- genCreate your first scenario
The ship editorcrates/nova_editor/
Shared UI theme / widgetscrates/nova_ui/
The web site / wikiweb/Building & running

The boot path in one glance

AppBuilder (in crates/nova_core/src/lib.rs) is the single place the app is wired – DefaultPlugins + window/log/asset/render setup, then the plugin stack (assets, gameplay, ship, scenario, HUD + NOVA OS monitor, editor, menu, debug). The state machines:

  • GameStates { Loading, MainMenu, Playing } – top-level lifecycle.
  • PauseStates { Unpaused, Paused } – the ESC overlay, nested in Playing.
  • GameAssetsStates { Loading, Processing, Loaded } – the asset pipeline that gates entry; on OnEnter(Loaded) the app hands off to MainMenu/Playing.

Gameplay systems run an explicit chain configured identically in Update and FixedUpdate; avian3d physics runs on a fixed timestep in FixedPostUpdate. The plugin order, exact sets, frame flow, and the Update-vs-FixedUpdate rule live in Architecture – start there once the shape clicks.

flowchart LR
    player["Player / AI input"] --> game["Game crates<br/>(nova_ship + nova_gameplay + nova_core)"]
    game --> scenario["nova_scenario<br/>(events / filters / actions)"]
    data["Data (RON)<br/>scenarios + mods"] --> assets["nova_assets + nova_modding"]
    assets --> game
    scenario --> game
    game --> screen["Rendered frame + HUD"]

Keeping docs in sync

Nova Protocol documents itself across several surfaces, each aimed at a different reader. None of them updates itself, so a code change is not finished until the docs it invalidates are fixed in the same task. This page is the map: what the surfaces are, what to touch when you change code, and what to do when you cut a release. It is the overview; the detailed, command-level steps live in Building and running under “Versioning and release” and “Writing the release news post”.

The documentation surfaces

SurfaceWhereAudienceKept in sync when
CHANGELOG.mdrepo rooteveryone (terse, complete)any user-visible change
Newsweb/src/news/*.md -> /news/players + modderseach feature release
Player wikiweb/src/wiki/*.md -> /wiki/playersplayer-facing behavior changes
Creator docsweb/src/create/*.md -> /create/mod authorsa data format or the modding UX changes
Dev book (this book)docs/ -> /dev/contributorsinternals, architecture, or the dev workflow change
rustdoclocal: cargo doc --opencontributorswith the code (doc comments travel in the same diff)

The tutorial is not a separate surface: it is the wiki’s Start-here landing (web/src/wiki/getting-started.md -> /wiki/getting-started/), updated when the first-flight flow changes.

CHANGELOG.md is the exhaustive machine reference (every version, terse, grouped by subsystem). News is the story (one rich post per feature release). The player wiki is the manual; /create/ is the modding manual and the exhaustive construct catalog. This book is the developer’s map - how to run, how to extend, where things live - and rustdoc is the API detail underneath it. They overlap on purpose - the cost of that overlap is that one code change can carry several doc obligations, which is what the map below makes explicit.

docs/ is the source of this book, not a scratchpad. Everything under it is a maintained book chapter, listed in SUMMARY.md and built on every deploy. Transient working files live outside the repo, and task-scoped records live in tasks/<id>/.

When you change code

Before you commit, ask three questions and act on each “yes”:

  1. Did user-visible behavior change? Add a line to CHANGELOG.md under ## [Unreleased], in the right subsystem section (Gameplay & Flight, Combat & Weapons, Ships & Sections, Scenarios & Objectives, Modding & Mod Portal, Interface & HUD, Web & Platform, Audio & Visuals, Performance, Fixes, Internals & Tooling). One terse line; tag a format break (breaking).
  2. Did player-facing behavior change (controls, HUD, a verb, a section, a weapon, a scenario primitive, the modding UX)? Update the player wiki page(s) it affects, and the tutorial if the first-flight flow moved. A wiki page drifting behind the game is the exact failure to avoid.
  3. Did internals, architecture, or a data format change? Update the book chapter(s) that describe them. A RON, bundle, catalog, or portal format change must also land in the matching /create/ reference or publishing page in the same task, or every mod author reads a lie.

The dependency map

Which docs to check when you touch a given area. “Check” means read it and fix it if the change made it wrong - not every change touches every listed page. Player wiki names are web/src/wiki/ pages; /create/... names are the creator pages on the site; linked names are chapters of this book.

Code area (crate / dir)Player wikiDev book / creator docsAlso
The crate layout itself: a split, merge, rename, or move (crates/*)Architecture (crate map + dependency graph + assembly), Project tour (crate map + change-X table), and THIS page’s own row keysCHANGELOG (Internals)
Ship sections, integrity, typed damage, ammo (nova_ship/sections, nova_gameplay/integrity)sections.md (+ section children), hud.mdShip sections internals, Add a ship section, /create/sections/CHANGELOG
How a body WEARS its damage: the two readings and the authored looks (nova_gameplay/integrity/{erosion,carve,spew,chunk}, nova_ship/sections/damage_*)sections.md (+ section children), combat-weapons.mdShip sections internals, /create/sections/ (damage_effects is a CONTENT format serialized into assets/base/sections/base.content.ron, so rule 3 applies), /create/base-content/CHANGELOG (breaking?)
Asteroid carving: the signed field, its cost model, severing (nova_scenario/objects/asteroid*, nova_gameplay/mesh/field.rs)combat-weapons.md, scenarios.mdScenario engine (the mechanism AND what a remesh costs), /create/objects/ (the Asteroid fields, and that radius is durability)CHANGELOG (breaking?)
The derived skin, its plate vocabulary and skin styles (nova_ship/sections/shell_*, skin_*, scripts/gen-greebles.py)sections.mdShip sections internals (the derived skin), /create/styles/, /create/base-content/ (style ids, greeble assets), /create/objects/ (the ship’s skin / style fields)CHANGELOG
Flight, controller, camera (nova_ship/input, camera)flight-autopilot.md, keybinds.mdArchitectureCHANGELOG
Targeting, radar, weapons, turrets, torpedoes (nova_ship targeting/sections, nova_hud)targeting-radar.md, combat-weapons.md, hud.mdArchitectureCHANGELOG
Gravity wells, factions, world (nova_gameplay)gravity-wells.md, factions.mdCHANGELOG
Scenario engine: events, filters, actions, variables, objects (nova_scenario, nova_events)scenarios.mdScenario engine, /create/author-a-scenario/, Extend the scenario engine, the creator reference (/create/scenarios/, /create/events/, /create/filters/, /create/actions/, /create/objects/, /create/expressions/ - a new/changed construct MUST land there, it is the exhaustive catalog)CHANGELOG
Modding data format, bundles, catalog, local cache (nova_mod_format, nova_modding)modding.md/create/mod-files/, /create/base-content/ (overlay rules, dep://base)CHANGELOG (breaking?)
The ship content kind: what a hull IS vs what a spawn is (nova_scenario/objects/ship.rs, nova_authoring/base_content/ships)sections.md/create/ships/, /create/objects/ (the Spaceship spawn), /create/mod-files/, /create/reference/CHANGELOG (breaking?)
Mod portal + generator (scripts/gen-portal.py, nova_modding)modding.md/create/publish-a-mod/CHANGELOG
Menus, editor, UI (nova_menu, nova_editor, nova_ui)hud.md, sections.mdAdd a ship sectiontutorial, CHANGELOG; theme tokens: web/design/nova_ui_rework_poc.html is the source for BOTH nova_ui/src/theme.rs and web/src/style.css; the site draws the PHOSPHOR skin only
Automation drivers, the env contract, the completion protocol (nova_autopilot, nova_debug/harness.rs)Automation harnessCHANGELOG (env rename? breaking for every run script)
ANY environment variable: a new one, a rename, a removal (NOVA_* anywhere in crates/ or src/)Environment variables - the index of the whole set, and the rule for adding onethe roster in tests/env_contract.rs (it fails until you do), CHANGELOG (breaking for every run script)
The probe harness: capabilities, run grading, the report (nova_probe, nova_probe_cli, nova_perf_web)Building and running (“Run verification”), Measuring performance, and the nova_probe rustdoc knob tableCHANGELOG (Internals)
What a frame COSTS, or how it is measured (nova_probe/capabilities/{frametime,framecost,census}, nova_core render setup, anything with a millisecond in its justification)Measuring performance - and re-read the Xvfb section before quoting an absolute number anywhereCHANGELOG (Performance)
App assembly, plugin order, states, the game binary’s own flags (nova_core, nova_assets, src/main.rs)Architecture, Project tour, Building and running (“Launching a scenario from the command line”)CHANGELOG
Content CLI: gen/lint subcommands, the base content builders (nova_authoring, the game binary’s content subcommand)/create/author-a-scenario/, /create/sections/, Add a ship section, /create/publish-a-mod/, /create/mod-files/, Scenario engine, Ship sections internals, /create/base-content/ (the id/asset catalog - a builder change that adds, renames or rebalances an id lands there)CHANGELOG
Packaging: the nix package, the desktop entry, the icon (flake.nix)Building and running (“The nix package”, “The desktop entry”)README.md quick start, CHANGELOG (Internals)
The website itself (web/)Building and running, this page
Local dev servers (scripts/serve-web.sh, scripts/serve-mods.sh, scripts/preview-web.sh, web/webpack.config.js, Trunk.toml)Building and running (“Local web preview”), /create/publish-a-mod/ (“Preview the repository portal”)README.md quick start + scripts table

“Check” means re-derive, not grep

A name-level sweep - “does the page mention the new crate?” - passes while every claim BETWEEN the names goes stale: a dependency graph drawing a crate with the wrong consumers, a crate map missing four members, a command attributed to the crate it moved out of. Someone then reads one of those and answers a question wrong, which is the failure the sweep was supposed to prevent.

So when a row above says “check”, it means: re-derive every crate name, module path, command, symbol and dependency direction the page asserts, against the current tree. A page that names the new thing can still describe the old one. Grep the page’s own file:line and symbol claims and open each one - a type that no longer exists is an unambiguous defect and needs no judgement.

Two structural traps this map cannot catch by itself:

  • The map’s own row keys are crate/dir names, so a structural refactor invalidates the MAP too - that is what the first row is for.
  • A lane-per-change epic has no lane whose job is the cross-cutting sweep. Give the sweep its own step (or lane) whenever crates/* changes shape.

When you cut a release

The full command-level checklist is in Building and running -> Versioning and release. At the doc level, a release means:

  1. CHANGELOG.md: promote ## [Unreleased] to ## [<version>] - <YYYY-MM-DD>, leave a fresh empty [Unreleased], merge any duplicate subsystem headings that grew during the cycle, and update the compare links at the bottom.

  2. News: a feature release (0.X.0) gets a new post at web/src/news/<version>.md, registered in NEWS_POSTS in web/webpack.config.js with a card added to web/src/news.html. A patch release (0.X.Y) gets no post of its own - fold it into its parent feature post’s ## Point releases section instead. Full steps: Writing the release news post. Post conventions the 0.7.0 post sets: structure the body as ## sections with ### subsections - the build derives the sticky TOC sidebar from those headings, so a flat post gets an empty TOC; and use the figure-placeholder format (a .figure block that auto-upgrades to its screenshot once scripts/gen-web-screenshots.py packages the image) rather than inlining an <img>.

    Name every figure news-<version>-<subject> and nothing else. A post argues about one release, so its media is frozen: both packagers refuse to overwrite a news- file that already exists (NOVA_UNFREEZE=news-0120 re-opens the post being authored), and a news- name is a leaf that nothing else may source from. A figure named for a living asset is re-cut every cycle and silently reillustrates the post; a living page borrowing a news- figure is pinned to an old release and can never update. web/tests/assets.test.js fails the build on either, so authoring a figure means adding an alias in scripts/gen-web-screenshots.py (stills) or scripts/capture-web-media.sh (loops).

  3. Wiki and creator docs: sync any player or creator pages the release’s changes touched (use the map above). Do this as you go during the cycle, not in a scramble at release.

  4. This book: same rule - chapters stay current through the cycle, and nix develop --command mdbook build must be green (a broken link is a build warning; treat warnings as failures). There is no release-time wipe step: docs/ carries no transient content to wipe.

  5. Build check: cd web && npm run ci (format check, lint, test, build) must be green; confirm /news/ and the new post render, and the section TOC sidebar is populated.

Adding or renaming a page

  • A chapter of this book: create docs/<page>.md and list it in docs/SUMMARY.md. mdbook build warns on a SUMMARY entry whose file is missing - treat that as a failure.
  • A site wiki page: add it to the manifest web/src/docs-manifest.js, which drives the page list, the sidebar, search and see-also (web/webpack.config.js reads it).
  • A news post: edit NEWS_POSTS in web/webpack.config.js plus a card in web/src/news.html.
  • Retiring a URL: add a stub to REDIRECTS in web/webpack.config.js.

Verify any of the web/ changes with cd web && npm run ci.

Architecture

New to the codebase? Start with the Project tour for a faster orientation, then come back here for the detail.

Nova Protocol is a 3D space shooter built on Bevy 0.19 with avian3d physics. It is a Cargo workspace: the root nova-protocol crate is a thin shell and all the real code lives under crates/.

Crate map

CrateResponsibility
nova-protocol (root)src/main.rs = clap CLI + entrypoint. src/lib.rs re-exports nova_core. Runnable examples in examples/.
nova_coreThin wiring only: AppBuilder assembles every plugin (window/log/asset setup, status UI). No gameplay logic.
nova_menuMain menu (owns the MainMenu state UI: New Game / Sandbox / Settings / Exit) and the ESC pause overlay. Buttons write GameMode and hand off to Playing. The Settings modal (audio volume, graphics preset, interface skin, and the Controls tab that REBINDS every action in nova_input, one binding group at a time) is shared by both entry points and persisted cross-platform in settings_store (RON file / localStorage), keybind overrides included.
nova_editorThe ship editor scene (NovaEditorPlugin). Comes up on entering Playing, only in GameMode::Sandbox.
nova_gameplayThe shared gameplay layer under the ship: integrity/ (health, the two damage readings erosion and carve, and the debris a carve leaves in spew/chunk), damage, gravity (gravity wells), markers (the entity markers the ship tags with and this layer reads), math, audio (the generic SFX engine nova_menu and nova_os_ui also use), juice, shake, settings (MasterVolume/GraphicsQuality + apply systems), mesh (the procedural TriangleMeshBuilder, plus the SignedField an asteroid is meshed from and carved in - nothing here takes a finished mesh apart), transform, relations, beacon, objectives (the GameObjectives list, its panel and the conveyance tags), lifetime (TempEntity/DespawnEntity), cooldown, plugin. Also owns GameStates, PauseStates, and the GameMode resource. Knows nothing about a ship.
nova_shipThe ship and how it is flown: sections/ (the modular hull, its ammo, and the authored damage looks in damage_effects/damage_cracks/damage_sparks/damage_plume), input/ (player rigs, the AI pilot and gunner, radar targeting with deliberate lock-on, and the flight and camera action DEFAULTS it registers into nova_input), flight/ (the diegetic controller and its autopilot verbs), camera/ (the chase-camera controller and the chase/skybox/post/WASD rigs under it), physics/ (the PD attitude controller) and ship_audio/ (the soundtrack those five produce). Depends on nova_gameplay and never the reverse; NovaShipPlugin owns the SpaceshipSystems brackets and nova_core adds it after NovaGameplayPlugin.
nova_hudThe flight HUD: one module per widget (crosshairs, target inset, ammo readout, flight status, objective markers, the comms panel, the keybind dock, the screen-indicator projection they all share). Reads gameplay state and never drives it, so the dependency runs nova_hud -> nova_gameplay. nova_core adds NovaHudPlugin render-gated, and the crate places NovaHudSystems between the section and camera sets itself.
nova_osNOVA OS logic with no UI in it: the terminal model (terminal), the shell command language and typo suggestions (shell), and the app runtime seam (app).
nova_os_uiThe NOVA OS cockpit monitor the player opens with Tab: the CRT casing and shader, the terminal nodes and keyboard/pointer systems (terminal), and the two apps that run on it - map (schematic local space) and ship (schematic player ship). A PEER of the flight HUD, not one of its widgets: nova_core adds it, and nothing in nova_hud reaches into it (it reads NovaHudAssets and NovaHudSystems, so it sits ABOVE nova_hud).
nova_scenarioScenario/modding engine: events, filters, actions, variables, world, loader, objects/, lint/ (the scenario half of the content -- lint checks), render_scale (the Low-preset resolution lever: scenario view into a reduced offscreen target, upscaled to the window). See Scenario engine.
nova_eventsGame event kinds and entity identity components, shared between gameplay and scenario.
nova_events_macrosProcedural macros behind nova_events’ derives.
nova_assetsbevy_asset_loader setup. Loads glb/textures/shaders/sounds, and loads the base game’s own generated content (assets/base/) through the same bundle machinery as mods. Owns the mod merge (register_bundles, EnabledMods, ModCatalog), the portal client and downloads (portal/), and prefs persistence.
nova_moddingBundle/content/catalog ASSET LOADERS and the Content routing enum. See Mod files.
nova_mod_formatPure serde types for the mod formats (bundle manifests, catalog declarations, the portal wire schema). Engine-free; re-exported by nova_modding. The static mod portal is built by scripts/gen-portal.py, not a crate. See Publish a mod.
nova_inputThe bindings registry, a leaf crate under every rig and every rebind surface: the one table (InputBindings) that says which named actions exist, what each is called on screen, and which physical sources it holds, plus the shared capture (poll::InputSources) every rebind row reads and the by-name dispatch. Owners register their own defaults into it; nothing here knows what an action DOES.
nova_uiShared UI, a leaf crate everything that renders UI draws from: the theme palette/metrics (theme::*), the UiSkin visual-language switch (skin), the themed widgets (widget: button, slider, segmented control, list rows, panel chrome), screen-level composition (screen: scrollable viewports and the list-beside-details layout the menu screens and the NOVA OS drawer share), the flight-HUD chip language (hud), player-facing unit formatting (units), the shared typeface (font), the generic status_bar and the keyboard-ownership arbiter (input_mode: one app-global InputMode resolved from per-frame claims, with InputModeSystems as the ordering handle every keyboard consumer gates behind). Consumed by nova_gameplay, nova_hud, nova_os_ui, nova_menu, nova_editor and nova_assets.
nova_debugDebug-only plugin (inspector, overlays). Compiled only under the debug feature.
nova_infoExposes APP_VERSION, injected by build.rs.
nova_autopilotScripted automation drivers and the run-completion protocol the harness examples share. Engine-facing but game-agnostic; nova_debug, nova_probe and nova_probe_cli all build on it. See Automation harness.
nova_probeDev tooling (not in the shipped game): the IN-GAME half of the run-harness - the capability plugins an example wires to collect evidence about its own run (capabilities:: frametime, timeline, invariants, snapshot, census, framecost, all bundled by NovaProbePlugin), the contract an example declares, and the wire format the host reads. See Measuring performance and Building and running.
nova_probe_cliDev tooling: the HOST half of the run-harness - spawns autopilot runs as child processes, grades their artifacts (evaluation) and renders the reports (report). Owns the cargo run --features debug probe run/report CLI. The two halves meet at the filesystem: nothing in nova_probe reads a run’s output back.
nova_perf_webThe wasm app probe run --platform web boots and measures: the real game started into a scenario with the frame-time capture armed. Dev tooling, never shipped.
nova_authoringThe OFFLINE half of the content pipeline (never shipped): the Rust builders that define every built-in scenario and section, the content -- gen serializer that writes them to the committed assets/base/**/*.content.ron, and the content -- lint walk that validates a content tree.
nova_meta_genBinary under tools/ (web-build tooling, not a game crate): writes default .meta sidecars for web assets that lack one (a Trunk post_build hook for AssetMetaCheck::Always). Boots a headless Bevy app, so it stays Rust.

The dependency layering the table describes, from top-level shell down to leaf crates:

graph TD
    root["nova-protocol (root)"] --> core["nova_core"]
    core --> menu["nova_menu"]
    core --> editor["nova_editor"]
    core --> gameplay["nova_gameplay"]
    core --> ship["nova_ship"]
    core --> hud["nova_hud"]
    core --> osui["nova_os_ui"]
    core --> scenario["nova_scenario"]
    core --> assets["nova_assets"]
    ship --> gameplay
    ship --> events["nova_events"]
    hud --> ship
    hud --> gameplay
    hud --> ui["nova_ui"]
    osui --> hud
    osui --> ship
    osui --> gameplay
    osui --> os["nova_os"]
    osui --> ui
    menu --> osui
    menu --> ui
    editor --> ship
    editor --> ui
    gameplay --> events
    gameplay --> ui
    ship --> input["nova_input"]
    hud --> input
    osui --> input
    menu --> input
    editor --> input
    scenario --> input
    scenario --> events
    scenario --> gameplay
    scenario --> ship
    scenario --> hud
    assets --> modding["nova_modding"]
    assets --> scenario
    modding --> modfmt["nova_mod_format"]
    core --> debug["nova_debug"]
    core --> info["nova_info"]

The graph is curated for readability, not exhaustive: nova_core and nova_menu depend on nearly every crate below them, and edges implied by the layering (for example nova_menu -> nova_ship) are pruned. The authoritative dependency list for any crate is its Cargo.toml. Two edges people guess wrong:

  • nova_ui is not menu-only. Every crate that renders UI draws on it - nova_gameplay (which adds NovaUiPlugin render-gated), nova_hud (the chip language and screen-indicator styling), nova_os_ui, nova_menu, nova_editor and nova_assets.
  • nova_scenario reaches up into nova_ship and nova_hud. It spawns ships and drives HUD-facing surfaces (comms dwell limits, target-inset render targets), so it sits beside them, not below them.

The dev-tool crates hang off this graph without joining it: nova_debug builds on nova_autopilot plus the game crates it inspects; nova_probe wires nova_core + nova_autopilot into a measurable app; nova_probe_cli depends only on nova_probe, nova_autopilot and nova_assets (it is a host process, not a game plugin); nova_perf_web is nova_core + nova_probe; and nova_authoring reads half the workspace to build and lint content offline.

Every crate exposes a pub mod prelude. Import from the prelude (use nova_gameplay::prelude::*), not from inner modules. nova_core::prelude re-exports all sub-crate preludes, so top-level code and examples usually just do use nova_protocol::prelude::*.

Generic helpers live here too

The generic, non-Nova Bevy helpers (WASD/chase cameras, skybox, post-processing, the mesh builder and the signed field, PD controller, health, status bar, the generic game-event queue GameEventsPlugin/EventWorld) are nova’s own: the camera and transform rigs, the mesh toolkit in nova_gameplay, the camera rigs and the PD controller in nova_ship, the status bar and tween in nova_ui, the event engine in nova_events, the inspector and wireframe layers in nova_debug. They live in nova crates on purpose: splitting a generic layer out before the game is done produces generic-looking code shaped by one game’s needs. Whether any of it deserves extracting is a question for after the game ships.

nova_ui::status_bar is the shape that line is drawn on. It is a generic readout - a row of value/colour closures over any Any subject - and it stays in the leaf crate. Nova’s own damage readout is NOT built on it: it is diegetic, and every section fractures its own material as its DamageLevel rises (nova_ship::sections::damage_cracks), from a vocabulary each section AUTHORS (damage_effects). That readout keys on Nova’s section graph, its authored vocabulary and an extended material, so it is game-specific and is not a promotion candidate. A generic widget and a diegetic one are different things at different layers, and the test is whether another game could take it as it stands.

Boundary policy, from most game-agnostic to most game-specific:

  1. The generic-leaning modules named above - reusable Bevy primitives that happen to live in a nova crate; keep them free of game-specific types.
  2. nova_gameplay - the shared gameplay layer, ship-agnostic.
  3. nova_ship - the ship above that layer.
  4. nova_hud and nova_os_ui - consumers of the ship and of gameplay, above both. nova_os_ui is above nova_hud in turn: it orders itself against NovaHudSystems.
  5. nova_core - wiring only.

App assembly

AppBuilder (in crates/nova_core/src/lib.rs) is the single place the app is wired:

#![allow(unused)]
fn main() {
AppBuilder::new()                 // Bevy DefaultPlugins + window/log/asset/render setup
    .with_game_plugins(my_plugin) // optional: your own systems/observers
    .build()                      // adds the plugin stack, returns App
}

AppBuilder::headless() is the same builder with no wgpu device, no window, no winit event loop and none of the visual game plugins. Rendering is fixed by the CONSTRUCTOR rather than by a setter, because DefaultPlugins bakes the wgpu and window settings the moment the builder starts - a later setter could not reach them. It is one switch and not two because the halves cannot be separated: bevy_hanabi panics without a render sub-app, so dropping the device forces dropping the plugins that need it.

build() inits GameStates + PauseStates, then adds, in order: EnhancedInputPlugin, GameAssetsPlugin, LoadingScreenPlugin, NovaGameplayPlugin, NovaShipPlugin (the ship orders its sets inside gameplay’s SpaceshipSystems brackets, so it comes after), NovaScenarioPlugin, then - render-gated, a headless harness run draws neither - NovaHudPlugin and NovaOsUiPlugin (HUD first: the monitor orders itself against NovaHudSystems), then NovaEditorPlugin and NovaMenuPlugin (both only when no custom game plugins were supplied - the menu fronts the default app and nothing else, so an example that brings its own game plugins goes straight Loading -> Playing), and finally DebugPlugin under the debug feature. On OnEnter(GameAssetsStates::Loaded) it hands off to MainMenu (or straight to Playing when the menu is off) and spawns the status UI.

NovaGameplayPlugin pulls in avian3d PhysicsPlugins (zero gravity, projectile collision hooks), bevy_rand, bevy_hanabi particles (on wasm via the WebGPU backend), NovaUiPlugin (render-gated), the transform/lifetime/mesh rigs, and the shared gameplay sub-plugins: integrity, damage, gravity, relations, audio, juice, settings. The ship stack (input, sections, flight, camera, physics) is NovaShipPlugin’s, and the HUD is NovaHudPlugin’s - both added by nova_core, not by gameplay.

States

  • GameStates { Loading, MainMenu, Playing } (nova_gameplay) - top-level lifecycle. MainMenu only occurs when NovaMenuPlugin fronts the app (the default editor app); examples with custom game plugins go straight Loading -> Playing. The GameMode resource (Sandbox default | NewGame) records what the menu handed off to.
  • PauseStates { Unpaused, Paused, NovaOs } - the freeze axis. Paused is the ESC pause overlay; NovaOs is the Tab ship-computer takeover (same clock freeze, cursor freed, no pause menu). Both frozen variants enter only from Unpaused and exit back to it, never into each other. nova_gameplay owns the enum and gates the spaceship sets; nova_menu owns the toggle, the overlay UI, and the clock freeze (Time<Virtual> + Time<Physics>). Only meaningful inside Playing; leaving Playing resets it.
  • GameAssetsStates { Loading, Processing, Loaded } (nova_assets) - asset pipeline. Scenario setup hooks OnEnter(GameAssetsStates::Loaded) - see examples/systems/system_scenario_grammar.rs.

The top-level lifecycle, the pause overlay nested inside Playing, and the asset pipeline that gates entry:

stateDiagram-v2
    state "GameStates" as GS {
        [*] --> Loading
        Loading --> MainMenu: menu app
        Loading --> Playing: custom game plugins
        MainMenu --> Playing: New Game / Sandbox
        state "Playing" as Playing {
            [*] --> Unpaused
            Unpaused --> Paused: ESC
            Paused --> Unpaused: ESC
            Unpaused --> NovaOs: Tab
            NovaOs --> Unpaused: Tab
        }
    }

    state "GameAssetsStates" as AS {
        [*] --> AsLoading: Loading
        AsLoading --> Processing
        Processing --> Loaded
    }

    AS --> GS: OnEnter(Loaded) hands off to MainMenu / Playing

Leaving Playing resets PauseStates back to Unpaused.

Frame flow

Gameplay systems run in an explicit chain, configured identically in Update and FixedUpdate. nova_ship::NovaShipPlugin declares the brackets and the ship sets; nova_hud slots NovaHudSystems into the gap itself:

SpaceshipSystems::First -> SpaceshipInputSystems -> SpaceshipSectionSystems
    -> NovaHudSystems -> NovaCameraSystems -> SpaceshipSystems::Last
  • Physics (avian3d) runs in FixedPostUpdate on a fixed timestep. Rigid bodies get TransformInterpolation so rendering stays smooth between physics ticks.
  • PostUpdate hosts the chase camera’s final move and the HUD’s world-to-screen projection, ordered after it.
  • While Paused, the input and section sets are gated off and the clocks freeze.
  • PreUpdate hosts nova_ui’s InputModeSystems, which resolves that frame’s ClaimKeyboard messages into the one InputMode every keyboard system then gates on. A claimant writes its claim .before(InputModeSystems); a consumer reads the resolved mode with in_input_mode, in_input_mode_at_most or owns_or_enters.

The render-rate chain (run in both Update and FixedUpdate) versus the fixed-timestep physics step and the interpolation that smooths rendering between ticks:

flowchart LR
    subgraph render["Update / FixedUpdate chain"]
        first["Spaceship First"] --> input["Spaceship Input"]
        input --> section["Spaceship Section"]
        section --> hud["Nova HUD"]
        hud --> cam["Nova Camera"]
        cam --> last["Spaceship Last"]
    end

    subgraph fixed["FixedPostUpdate"]
        phys["avian3d physics (fixed step)"]
        phys --> interp["TransformInterpolation"]
    end

    subgraph post["PostUpdate"]
        chase["chase camera final move"] --> worldscreen["HUD world-to-screen"]
    end

    render --> fixed --> post

Update vs FixedUpdate - which schedule does my system go in?

The chain above is configured IDENTICALLY in Update and FixedUpdate (nova_ship::NovaShipPlugin, two configure_sets calls with the same set order), so a gameplay set can host systems in either schedule. The split is not cosmetic: since every dynamic body opted into avian’s TransformInterpolation, the game carries two pose representations on two clocks:

  • Raw physics pose – avian Position/Rotation, advanced on the 64 Hz FixedUpdate tick. This is the truth the simulation integrates.
  • Render poseTransform, eased between the previous and current physics states, with GlobalTransform propagated from it in PostUpdate.

Which schedule:

  • Put a system in FixedUpdate when it feeds the physics sim – forces and impulses, spawns whose motion the fixed clock advances (torpedoes, which physics integrates; gun rounds, which nova_gameplay::rounds sweeps by hand after the physics step), guidance. It MUST read the raw Position/Rotation (or compose the root’s raw pose with a local mount offset). During FixedUpdate of frame N, GlobalTransform still holds the eased pose propagated in frame N-1’s PostUpdate, so it is stale render state here; the avian child-collider pose is one tick stale too.
  • Put a system in Update (or PostUpdate) when it consumes the rendered frame – camera, HUD world-to-screen projection, effects. It reads the eased Transform/GlobalTransform, and every pose in one on-screen computation must come from the same frame. A consumer of PostUpdate-written state must be ordered after its producer.
  • Put a system in FixedUpdate when what it computes DECIDES a fixed-step consequence, even when the same value is also drawn. This is the rule that is easiest to get wrong, because such a system looks like render-rate work: it has an on-screen output, so it reads as belonging beside the camera and the HUD. Ask instead whether anything on the fixed clock BRANCHES on what it writes. If it does, the state that branch samples advances once per FRAME while the branch is taken once per STEP, and the outcome is a function of the host’s frame rate.

Why gameplay is split across both: the chain runs in Update for render-rate work and in FixedUpdate for sim-rate work; the same set order in both keeps ordering consistent wherever a system lands.

The fixed loop runs on the SINGLE-THREADED executor (AppBuilder::assemble), so a FixedUpdate system gets no parallelism from its neighbours - only from its own par_iter. That is a deliberate trade for the schedules’ size; the number behind it is in Measuring performance.

What breaks if a system lands in the wrong schedule – worked example: a FixedUpdate system reading GlobalTransform. thruster_impulse_system used to apply its impulse at the thruster child’s GlobalTransform, i.e. the previous frame’s eased pose, up to ~2 ticks of ship motion behind the raw physics it was pushing, while taking thrust DIRECTION from the raw Rotation – mixing both clocks in one impulse. The application-point error is proportional to velocity; at speed a COM-centered engine developed an uncompensated lever arm the throttle balancer could not see, and the measured failure was a zero-true-torque lateral engine spinning the hull to 7.1 rad/s in 15 frames (0 rad/s after reading the raw pose). The fix (thruster_section.rs, see the comment above apply_linear_impulse_at_point) composes both application point and thrust direction from the root’s raw Position/Rotation. The same footgun produced the bullet-spew, HUD-jitter and crosshair-twitch bugs in that family; all were error proportional to velocity, which is why they only showed at high speed.

Worked example for the third rule – a DRAWN value that gates a fixed-step branch. A turret’s aim chain solved the intercept, drove the hinges and wrote the joint pose in PostUpdate, which is where a thing you can see belongs. But shoot_spawn_projectile runs in FixedUpdate and asks, per tick, whether the muzzle is inside a 0.92 deg cone of that aim point. The barrel’s pose was therefore a staircase with one step per FRAME, its tracking residual scaled with the frame period, and the cone is narrower than the residual’s span: measured on stress_point_defense, the battery held its trigger 9.7% of the time at 20 fps and 60.6% at 106 fps – same scene, same seed, a six-fold difference in how strong point defence was. Moving the whole chain (solve, hinge controller, joint sync) onto the fixed clock made the trigger duty 0.811-0.817 across the same span. Nothing about the aim chain looked like sim work; the tell was that a FixedUpdate system branched on its output.

Cross-system communication goes through events and observers (Bevy On<...> observers, e.g. the integrity/destruction chain) rather than direct calls. Prefer adding an event/observer over coupling two systems.

Assets

assets/ is runtime-only - everything the game actually loads: shaders/ (.wgsl), icons/, sounds/ (UI chrome: menu clicks + objective chimes), and the base//mods/ data (.ron). The base game’s own art and world audio live UNDER assets/base/ (exported gltf/ models .glb, textures/, sounds/ .wav world cues, banner.png), referenced by base content with self:// and by mods with dep://base/<path>. It is the whole directory the web (Trunk copy-dir) and native (release.yaml) builds ship, so non-runtime files must not live here. The Blender SOURCES the gltf/ models are exported from live OUT of the shipped tree, in top-level art/blender/, because nothing loads them at runtime. The built-in sections, ships, styles, scenarios and campaigns ARE data: the Rust builders under crates/nova_authoring/src/base_content/ (sections/, ships/, styles.rs, scenarios/, campaigns.rs, assets.rs) are the single source, and cargo run content gen serializes them to the committed assets/base/**/*.content.ron the game loads like any other bundle. Never hand-edit the generated files; edit the builders and re-run gen.

Find it in the code

  • App assembly, plugin order: AppBuilder - crates/nova_core/src/lib.rs; game binary and CLI flags - src/main.rs.
  • States: GameStates, PauseStates, GameMode - crates/nova_gameplay/src/lib.rs; ESC overlay and clock freeze - crates/nova_menu/src/pause.rs.
  • Frame-flow sets: SpaceshipSystems - crates/nova_gameplay/src/plugin.rs; chained in Update + FixedUpdate by NovaShipPlugin - crates/nova_ship/src/lib.rs.
  • Who owns the keyboard: InputMode, ClaimKeyboard and InputModeSystems - crates/nova_ui/src/input_mode.rs; the editor’s claimant - declare_editor_keyboard_owner in crates/nova_editor/src/lib.rs.
  • The two damage readings: DamageLevel - crates/nova_gameplay/src/integrity/erosion.rs; DamageMarks and the carve cost model - crates/nova_gameplay/src/integrity/carve.rs.
  • Asset gate and mod merge: GameAssetsPlugin - crates/nova_assets/src/plugin.rs; register_bundles - crates/nova_assets/src/merge.rs.
  • API detail: cargo doc --open -p nova_core (any crate from the map works).

Spaceship sections and integrity

To add a new section kind, follow the guide Add a ship section.

Ships are assembled from modular sections. Each section is a child entity of the ship root with its own collider, mass, and health, and contributes one behavior (structure, thrust, steering, guns). The integrity system tracks how sections connect and handles damage, disabling, and cascading destruction.

Sections (nova_ship::sections)

A section is a SectionConfig { base: BaseSectionConfig, kind: SectionKind }. BaseSectionConfig is shared by all kinds: id, name, description, health, optional impact_sound / destroy_sound, optional collider, structural link_points, hide_in_editor, and damage_effects - the authored list of looks this section wears as it is damaged (see Damage is two readings).

SectionKind variants (one module per kind under crates/nova_ship/src/sections/; turret_section/ and torpedo_section/ are directories, not single files):

KindWhat it does
HullPassive structure/armor. Just a render_mesh.
ThrusterForward thrust (magnitude); drives the exhaust visual.
ControllerAttitude controller (steering_lag, max_torque); lag derives the internal PD gains, torque feeds the hull’s attitude envelope (see below). Also grants flight verbs (STOP/GOTO/ORBIT maneuvers plus LOCK targeting and RCS fine-translation). A ship needs one to be drivable; several SHARE one attitude loop.
TurretAims and fires bullets. An authored joint tree (hinges + muzzles, each joint with its own offset/axis/speed/limits/render_mesh), section-wide muzzle_speed + authored bullet_damage + bullet_kind, per-muzzle fire_rate, optional ammo_capacity.
TorpedoTorpedo bay. Fires guided torpedoes of an authored torpedo_type (name, tint, max_speed, weave_angle, weave_rate) that detonate an Explosive area blast (blast_radius, blast_damage), optional ammo_capacity. The TYPE is the run-in - how fast and how evasively; everything else on the config is the tube.

GameSections(Vec<SectionConfig>) is the resource of section blueprints. Generic prototypes are authored in crates/nova_authoring/src/base_content/sections/standard.rs; semantic craft parts live under base_content/ships/. Their explicit section_catalog() is generated into assets/base/sections/base.content.ron by content -- gen and merged into the resource by crates/nova_assets/src/merge.rs. The outer-skin cladding is not a prototype at all: a ship’s skin is DERIVED from the structure it wraps by nova_ship’s shell_skin (below), so no id names a plate. Look a section up with sections.get_section("basic_thruster_section").

The attitude envelope, and how controllers stack into it

How hard a hull may turn is DERIVED, not authored. AttitudeEnvelope (crates/nova_ship/src/physics/attitude.rs) is two ceilings and the lower of them:

alpha_max = min( sum(max_torque) / I ,  LOAD_LIMIT / (r * METERS_PER_UNIT) )
                 \___ propulsive ___/   \________ structural ________/
  • I is the hull’s largest principal moment of angular inertia, read off avian’s ComputedAngularInertia. No formula for it lives in Nova: it is the second moment of where the mass actually sits, so no curve in any single length can stand in for it.
  • r is the structural arm, from the hull’s centre of mass to the outer FACE of its furthest live section, in world units. structural_arm derives it; BodyRadius is a scenario-obstacle radius and is not it.
  • LOAD_LIMIT and METERS_PER_UNIT are in crates/nova_events/src/scale.rs - one definition each, shared by the physics and by the player-facing formatter in nova_ui::units.

The two structural loads are perpendicular components of one acceleration at the tip, so they add as a VECTOR: alpha^2 + omega^4 <= (LOAD_LIMIT / r_m)^2. A hull already in a hard turn has spent its margin, and its sustained rate is sqrt(LOAD_LIMIT / r_m). Past that corner the centripetal load alone is over the limit and the full ceiling comes back, so an over-spun hull can always shed rate.

Two consequences to expect rather than debug. A small hull is STRUCTURE-bound, so fitting more computers to it changes nothing at all. A hull that loses sections shortens its arm and turns SHARPER than it did intact.

Every live controller torques the hull in parallel, so a hull with several of them would multiply both its gains and the applied torque by the section count. update_controller_stack_tuning (crates/nova_ship/src/sections/controller_section.rs) prevents that: it runs first in FixedUpdate (ControllerSectionSystems::SyncStack), derives ONE ship-level attitude loop per root, and writes each live controller a share of it into its PDController. The authored numbers stay put in ControllerSectionTuning, which is what the pass re-derives from when a controller dies. The smallest live steering_lag supplies the stack’s base response.

The ship-level loop, for n live controllers:

  • authority: the envelope above. Torque SUMS across controllers, with no curve and no cap of its own, because the structural ceiling already caps the result. Each controller’s share is its own fraction of the hull’s torque.
  • P gain: DIVIDED by stack_curve(n, 1.5) = 1.5 - 0.5 / n, which increases the effective steering lag so the stack brakes earlier and lands on the commanded attitude instead of sailing past it.
  • D gain: held at exactly one fastest computer’s worth. This is not tuning: kd * dt crosses 2 at two controllers on the shipped tuning, and past that the PD limit-cycles instead of parking (the corkscrew that used to follow a released maneuver).

ship_turn_rate (flight/guidance.rs) then sums the live acceleration shares, which is why the flight layer is ordered after SyncStack. n = 1 is the identity case.

The precision division is the one part of the stack that a structure-bound hull does not pay for: it has no extra authority to spend, so a stack costs it a few per cent of peak rate and a tick or two of onset in exchange for landing cleaner. Measured in flight/tests/stacking.rs, which prints the table.

Meshes and colliders (authorable)

Two authorable knobs decouple a section’s look and physics from the default unit cube (crates/nova_ship/src/sections/base_section.rs). Unset content still uses the unit-cube defaults:

  • render_mesh_transform (optional, on every mesh-bearing kind; for turrets it sits per JOINT in the joint tree) - an offset / rotation / scale applied to the section’s render mesh child ONLY, so a model can be re-seated visually without moving the collider or (for turrets) the joint tree. Type RenderMeshTransform.
  • collider on BaseSectionConfig (optional) - the physics shape: Cuboid { size }, Sphere { radius }, Capsule { radius, length }, or Cylinder { radius, height } (the last three along local Y). None resolves to the unit cube (Cuboid { size: (1,1,1) }) - the shape every section had before colliders were authorable. base_section hands avian a density of 1, so this shape’s volume IS the section’s mass and a larger collider is a heavier one. The render mesh never contributes; a section masses its authored box. An integral cuboid at least one unit wide on every axis also derives a SectionFootprint of those dimensions. read_structure expands that one entity across every occupied cell, and clearance emits one exit lane from every cell on its exhaust face. Other collider shapes remain one-cell parts.

Building a ship

A SpaceshipConfig (crates/nova_scenario/src/objects/spaceship.rs) has a controller (None, Player, or AI), an allegiance, an optional collapse_threshold (below), a skin flag (the derived cladding), and a list of SpaceshipSectionConfig, each placing one section at a position + rotation relative to the ship root (world units), with a source (Inline / Prototype) and optional modifications. The player config carries the input mapping (section id -> key/gamepad bindings) plus speed_cap and infinite_ammo; the AI config carries patrol/orbit/leash/engage_delay.

Spawning: the base scenario bundle gives the root RigidBody::Dynamic; the spaceship object adds SpaceshipRootMarker, and an observer (insert_spaceship_sections) spawns each section as a direct child. Every section gets SectionMarker, its Collider (the authored collider shape, or a unit cube by default), SectionLinkPoints, ConnectedTo, and Health (base_section in sections/base_section.rs), so the ship is one rigid body whose child colliders each carry their own health.

See the semantic Racer, CargoA, and CargoB builders under crates/nova_authoring/src/base_content/ships/ for complete generated examples. The editor (crates/nova_editor) assembles ships interactively using preview_section, which has no health or rigid body and never enters the damage pipeline.

The derived skin

skin: true on a ship’s hull asks the game to CLAD it. Nothing authors a plate: spawn_ship_skin (sections/shell_skin.rs) reads the finished section batch on the same Added<SectionLinkPoints> edge the integrity graph is built off, buckets the sections into cells (the lattice is read off the sections, so a hull mirrored about its centreline is not cut in two), and derives one plate per cell of outer surface from the eight boundary samples that cell shares with its neighbours. The same structure always gives the same skin.

  • A plate is a SectionFixture (sections/fixture.rs): Collider, Health, density and HealthIsolated, but no SectionMarker. So it never joins the integrity graph, never counts toward the ship’s health, never wears a section’s damage effects (owning_section in damage_cracks.rs stops its ancestor walk at the first fixture), and never reaches the palette. Losing one costs the ship nothing it can DO - which is the line between a fixture and a section.
  • Each plate is a CHILD of the section it clads, so a destroyed section takes its own cladding down with it and nothing has to hunt the plates of a part that no longer exists.
  • On a LIVE ship, derivation runs at spawn and nowhere else. The skin is a pure function of the structure, so re-running it would grow back whatever combat blew off; despawn_dead_fixtures takes a dead plate away and nothing puts it back.
  • ShipSkinPlugin { render } is split at the render line, not at the look line: the derivation and the sweep are gameplay and run headless, and render gates only the meshes hung on each plate by the dress_skin_plate observer.

Cladding is OPT IN. The derivation reads a hull as unit cells, which the catalog’s cube sections are and the modelled semantic parts (the racer, the haulers) are not.

The editor’s live preview

The build view clads the ship being ASSEMBLED, and it re-derives rather than spawning once: sync_editor_skin (nova_editor/src/skin.rs) runs after sync_placement_ghost, hashes the structure it is about to read, and respawns the whole skin when that hash moves. Nothing is patched and nothing is diffed - the derivation is a pure function, so throwing the plates away and asking again is both the simplest answer and the one that cannot drift. On a 384-plate ship a reflow costs about 2 ms and an unchanged frame about 0.1 ms; a real build is an order of magnitude smaller than that, and the ghost only travels in whole cells (placement mates sockets), so dragging a part does not re-derive per frame.

Two things it does differently from the spawner:

  • The part UNDER THE POINTER is structure, while its placement is legal. That is the feature: a hull is dragged about under the skin and the cladding closes around it before the click. A REFUSED ghost contributes nothing - it will not be built, so cladding it would draw a ship that cannot exist.
  • A preview plate is DISPLAY ONLY: ShipSkinMarker and a pose, so the shared dress_skin_plate observer still draws it, but no SectionMarker, no Collider and no health. The placement solver never counts one as a part, the pointer never hits one, and the Q pipette cannot arm one.

Both readings go through read_structure (shell_skin.rs), so the lattice the editor clads on and the lattice the flown ship clads on cannot drift - and the build state carries the toggle into the SpaceshipConfig the scenario spawns, so what you see in the editor is what you fly.

The plate vocabulary and skin styles

The derivation works out far more than it keeps. read_plates (sections/skin_reading.rs) is a SECOND PASS over the finished plates that reads it back out as a PlateReading each: which way the plate faces, what its top is shaped like (Flat / Step / Ridge / Peak / Bevel / Brink / Spur), which way it falls away, how enclosed its cell is, how long the run of like plate through it is and which way that run points, how far it is from the end of that run, how much of its cell it fills, how deep the structure under it goes, and how close the mouth of a fitting is. The plates are the whole input - their cells are the clad set, cell - anchor is the face each shows - so a reading cannot drift from the skin it describes. It costs about 0.6 ms on a 384-plate ship, against 1.6 ms to derive the skin itself.

The FALLING PLATE is three reliefs and not one, which matters because it is four fifths of every ship. A corner sample dies to the cell floor for exactly one reason - open space stands at it - so counting the dead corners says how many ways a plate falls: one corner is a Bevel (a panel with a corner taken off), two on one side is a Brink (the straight edge of a hull), and anything more is a Spur (a tip, an outer corner, a saddle). Summing those corner directions and turning them by the plate’s own rotation gives PlateReading::fall, the OUTBOARD direction - a cardinal on a Brink, a diagonal on a corner, and zero where a plate falls two ways and cancels. It is the second alignment axis: a piece turned to along lies down an edge, and one turned to fall leans out over it.

A ShipStyleConfig (sections/skin_style.rs) is CONTENT resolved by id out of GameStyles, exactly as a section prototype resolves out of GameSections. It carries a material per surface role and a list of decoration fixtures, each with a model AssetRef and a ScatterRule written in the vocabulary above. The mod merge routes Content::Style into GameStyles with the same last-wins overlay every other kind gets, so a mod restyles a base look by declaring its id.

scatter_decor (sections/skin_decor.rs) turns plates plus readings plus a style into placements. It takes the READINGS, not the structure, so the scatter cannot reach past the vocabulary into the derivation. Two properties are load-bearing:

  • DETERMINISM. There is no RNG. A plate’s claim is a hand-written FNV-1a hash of its cell, its out face and the fixture’s id - hand-written because DefaultHasher is not promised to be stable across releases of the standard library, and a ship that comes back wearing different antennae after a toolchain bump would break the same promise the derived skin exists to keep. The editor re-derives and re-scatters on every structure change, so anything less would flicker while a hull is dragged.
  • GRID CLAIMING, not blue noise. A rule claims cells on its own stride and a piece is yawed to PlateReading::along or to PlateReading::fall. Poisson sampling deliberately destroys alignment, and alignment is the difference between decoration that reads as bolted on and decoration that reads as confetti.

One thing is decided by a BLOCK of hull rather than by a cell, and it is the DENSITY NORMALISATION. Every other knob is per plate and they multiply, so a rule tuned on a 150-plate generated hull put one visible piece on a 20-plate editor build. ScatterRule::patch is a floor: within each block of patch cubed cells, keyed by the out face, a rule that the share left with nothing claims its lowest hashing eligible plate. A block is a fixed division of the ship’s own cells, so a hull that grows by one cell keeps every piece outside the block it grew into; the floor never displaces another rule’s piece, so priority still means what it says. With chance: 0.0 the share picks nothing and the rule is purely “one piece per block”, which is a density that reads the same at any hull size.

A decoration is a SectionFixture like a plate, and a child of the PLATE, one level further out - so a plate shot off takes its greebles, and damage_cracks’s owning_section walk stops at the first fixture it meets whichever of the two it started under. The base game generates its greeble models from committed JSON recipes (scripts/gen-greebles.py), and the mod-facing format is documented in Ship skin styles.

What a hull actually OFFERS is worth knowing before writing a rule, and a GENERATED hull and a HAND-BUILT one offer almost opposite things. Measured, per ship:

subjectplatesflatstepridgepeakbevelbrinkspur
wfc_ships row132-1626-2218-220-4010-1448-6634-42
editor build19-2702-33-81-3009-17

A hand-built ship has NO flat plate, no bevel and no brink at all: it is spurs, ridges and studs, because almost every cell of it is one cell wide. So a rule written for flat panels lands nowhere on the thing the owner actually builds, and no density normalisation can rescue it - a floor over an empty eligible set is still empty.

Both spawn_ship_skin and the editor’s sync_editor_skin log that histogram plus a per-rule taken of reach tally at debug, where REACH is everything the rule’s filter and lattice admit before the share and before priority. The two zeroes mean opposite things: x0 of 78 is a rule starved by one above it or thinned away by its own share, and x0 of 0 is a filter that matches nothing this hull has.

Integrity: damage -> disable -> destroy

The destruction stack is nova’s own, in crates/nova_gameplay/src/integrity/, with the ship adapter in crates/nova_ship/src/sections/integrity.rs. NovaIntegrityPlugin composes eight generic pieces, and the ship adds its own ShipIntegrityPlugin on top:

  • health.rs - the hit-point store: Health, HealthApplyDamage and the HealthZeroMarker its observer adds at zero.
  • core.rs (IntegrityCorePlugin) - the generic disable/destroy core, plus the mass-times-velocity impact damage.
  • erosion.rs (DamageLevelPlugin) and carve.rs (DamageMarksPlugin) - the two damage READINGS, below.
  • spew.rs (CarveSpewPlugin) and chunk.rs (CarvedChunkPlugin) - what a carve leaves behind: dust from every carve, and a real rigid body wherever a cut actually severed material.
  • Ship-owned ShipIntegrityPlugin (nova_ship, not one of the eight) - derives the section graph, handles disabled sections, rolls section health up to the ship root, and collapses a root that falls below its StructuralCollapseThreshold.
  • explode.rs (ExplodablePlugin) - what a destroyed body DOES, and it detaches. See How a body comes apart below.
  • neutralize.rs - combat-death: fires OnNeutralized when a ship stops being a threat.

Graph build: every section prototype authors local link_points with an id, position, and outward unit normal. When avian links a collider to its body (ColliderOf), ShipIntegrityPlugin transforms those points into ship-root space. Coincident points with opposed normals become symmetric ConnectedTo neighbor edges. IDs are for diagnostics and UI, not compatibility. A malformed, ambiguous, or disconnected graph is rejected as a whole; collider contact and center distance never create fallback edges. SpaceshipRootMarker requires IntegrityRoot AND DamageMarks: a ship’s hits belong to the ship, not to whatever collider stopped them, because a crater has to cross the seams between the plates and sections it reaches. Asteroids are not in the graph at all - a rock carries no Health and no IntegrityRoot, only DamageMarks, and its death is decided by its own remesh (see Scenario engine).

How a body comes apart

Nothing computes geometry when something dies. A destroyed ExplodableEntity DETACHES: it comes off its parent, becomes a rigid body of its own carrying the art and the collider it already had, takes an outward kick and a spin, and despawns on a timer (PIECE_LIFETIME_SECS). A death moves entities and clones one collider handle, which is why the module has no fragment budget and no spawn queue - there is nothing left to ration.

Four properties follow from that, and each is load-bearing:

  • Destructibility is SEMANTIC. It is ExplodableEntity plus the destroy marker, never where a Mesh3d happens to sit. A section carries its health, its collider and the marker on a gameplay root while its art hangs off SectionRenderOf descendants under a gltf WorldAssetRoot, so anything gated on Mesh3d at the gameplay entity finds nothing on any section ever shipped. Detaching asks about neither: the children come across whatever they are, and the COLLIDER is what says a body can be one.
  • An IntegrityRoot is excluded. ExplodableEntity propagates to parents, so a root carries it too - and detaching a root would take the whole structure off at once and bypass every node’s own death. The test names IntegrityRoot rather than SpaceshipRootMarker because a severed wreck and an asteroid husk are roots that are not spaceships, and because this layer stays free of anything nova_ship owns.
  • The plates and greebles ride the wreck out, their colliders do not. Every direct child moves, still bolted on, which is what a piece breaking off a hull looks like and is cheaper than making each one its own body. The colliders are stripped on the way, because avian attaches every collider under a rigid body to that body: a section wears a plate per face and greebles per plate, so a wreck that kept them is one body with dozens of shapes, times every section a collapse kills in the same frame. The section’s own collider is the shape the wreck is.
  • A piece is born inside the body it left, which is a dynamic body spawned interpenetrating another - something the solver fixes by shoving hard. So it takes the same ChunkGrace a carved rock chunk takes: kinematic and colliderless until it has drifted clear.

There is NO FALLBACK for a body with no collider: it leaves nothing behind and logs an error, and system_destruction_finale asserts that branch never runs on shipped content. A refusal rather than a stand-in, because a stand-in looks like SOMETHING - a body that had silently failed to come apart would be indistinguishable from one that came apart badly.

Editor placement mates the same sockets, so the editor cannot build a ship the graph would reject. snap_placement (nova_ship::sections::link_points) poses a part from one mate: the two sockets become coincident and their normals opposed, which leaves only the ROLL about that axis free - the builder’s choice, alongside which of the part’s own sockets does the mating.

That roll has a defined ZERO, and it is what makes one part usable on every other part. Each socket carries an implied up vector, link_point_up(normal): ship up (+Y) projected onto the socket’s plane, or forward (+Z) on the sockets that face +-Y. It is DERIVED from the normal rather than authored, so two parts that never met agree on it without anyone writing a second vector, and snap_placement mates the two socket FRAMES rather than just the two normals. Aligning normals alone leaves the roll to whichever axis a shortest-arc rotation happened to sweep about - and for a socket facing exactly opposite the part’s own, to an arbitrary perpendicular.

The other half is the normals themselves. An authoring tool that derives a socket from part GEOMETRY gets whatever angle the neighbour happened to sit at, so cardinal_axis snaps the derived normal to the nearest axis. It is antisymmetric (cardinal_axis(-d) == -cardinal_axis(d)), so both ends of one authored edge stay exactly opposed and no existing mate is lost. Without it the cargob’s pod faced its fuselage 36 degrees off -X and anything mated onto that socket arrived tilted by exactly that much - which is what made parts look like they only fit the craft they were cut from.

box_link_points(size) is the general face-socket helper (unit_cube_link_points is box_link_points(Vec3::ONE)). A part authored at its own size mates against a part of any other size, because the sockets meet face to face and the roll comes from the axis alone; pdc_kinetic_turret_section (and its pdc_pierce_turret_section twin, the same gun with a different round) is the shipped example - one compact mount that fits every hull, replacing ten per-craft copies of the same gun.

candidate_link_point_mates is the same pairing WITHOUT the ambiguity and connectivity gates, because a ship under assembly is legitimately disconnected; the editor uses it to see which sockets are taken and to refuse a placement that would leave one with two suitors. Collider bounds enter only as the overlap refusal, under the ship lint’s rule: interpenetration is allowed exactly where a mate says the interface is intentional.

scripts/cut-obj-into-parts.py proposes candidates for freshly cut parts: two parts whose bounds meet at a seam and overlap across it get one socket each at the centre of that shared face, written into the part manifest as link_points. A recipe part can author its own list instead (in ship space, like every other recipe coordinate), which replaces the generated one. They are candidates for a human to judge - shipped gameplay sockets stay hand-authored in nova_authoring.

Damage flow:

  1. A hit triggers HealthApplyDamage (nova_gameplay::integrity::health); its observer subtracts the amount and adds HealthZeroMarker at zero. The amount also bubbles up ChildOf, clamped to what the section actually had left - so overkill on one section cannot kill the ship (a 1000 hit on a 100 hp section costs the root 100). apply_damage also takes an at: Option<Vec3> and records a mark there, so a hit says WHERE as well as how much; the ram path passes the rammer’s transform.
  2. Zero health -> IntegrityDisabledMarker. A depleted ship section is destroyed at any graph degree. The leaf rule remains for healthy sections disabled by final structural collapse.
  3. Destruction prunes the node from its neighbors’ lists. If surviving structure becomes disconnected, the controller-bearing component keeps ship identity and every other component becomes an inert dynamic wreck body.
  4. aggregate_ship_health keeps the root’s current equal to the sum of its living sections, over a max that is PINNED - a running maximum, never re-derived from the survivors. A destroyed section despawns, so a live denominator would make the HP bar fill up as a ship is shot apart (150/1100 reading 100/100) and would make any fraction of it rebound. It is a running maximum rather than a set-once pin because a ship’s sections can land across several frames.
  5. At or below its StructuralCollapseThreshold (collapse_threshold on the ship, default 0.05) the root gets StructuralCollapseMarker and the ship starts TEARING ITSELF APART, rather than dying on the spot: cascade_structural_collapse disables every section still standing and hands them to steps 2 and 3 above. The extremities are leaves, so they go first and burst their debris; the prune turns their neighbors into leaves, and those go on the next frames. The wreck peels from the outside in instead of vanishing - how long that takes is the remnant’s DEPTH, so a chain peels from both ends over several frames while a shallow remnant whose sections are all already leaves goes in one. Every section’s own debris burst fires either way.
  6. The ROOT dies last, and of the same rule. Each destroyed section leaves the sum, so step 4 walks current down to zero on its own; with no structure left the recompute marks the root HealthZeroMarker and the ordinary chain destroys it, which is what fires OnDefeated/OnDestroyed. That is also the standalone backstop for a last section removed WITHOUT a damage bubble (a direct destroy, a detach), which nothing else would mark, and it is what threshold 0.0 reduces the whole rule to.

The no-progress override. Disabling a section costs it no health - only DESTRUCTION takes it out of the root’s sum - so a remnant with no leaf never drains. Four hulls mated in a ring each keep two neighbors, none ever becomes a leaf, nothing is destroyed, current never falls and the root never dies: an immortal disabled hulk. So the leaf rule is treated as a preference for the ORDER a wreck comes apart in, not a correctness requirement. A cascade tick that disables nothing new AND does not see the standing-section count fall is a stall, and the most leaf-like survivor is destroyed whatever its neighbors. Breaking one node out of a ring leaves a chain, so the ordinary cascade resumes and the peel is kept everywhere it is possible. Progress is measured as that count FALLING rather than against a frame budget, because the cascade’s own gaps are irregular while a count that fell is direct evidence a section died.

A severed wreck fragment is persistent until scenario teardown. Its healthy sections remain damageable, but SectionInactiveMarker disconnects every controller, thruster and weapon from the lost command bus. Fragments inherit rigid point velocity and receive a momentum-balanced 1 u/s kick away from the cut. They are unsigned debris, not ships: no allegiance, control, defeat event, or scenario identity.

A ship is disabled progressively, so a collapsing ship can keep shooting for a few frames; its weapons stop as their own sections go. That also means the unified defeat edge usually comes from neutralize.rs partway through the peel, and the root’s later destruction fires only OnDestroyed - DefeatedMarker is what keeps OnDefeated to exactly one.

Structural collapse is a MATERIAL test and stands apart from neutralization (neutralize.rs), which is a CAPABILITY test: a ship can be out of the fight with a sound hull (a derelict to board, salvage or let limp away), and a ship can collapse while its guns still work.

The cascade a single section walks through:

flowchart TD
    A[Section takes damage] --> B[Integrity drops]
    B --> C{Zero health?}
    C -->|No| A
    C -->|Yes| F[Destroyed]
    F --> G[Pruned from neighbors]
    G --> H{Graph still connected?}
    H -->|No| P[Detached components become wreck bodies]
    F --> I[Root current re-aggregated over a pinned max]
    I --> J{At or below the collapse threshold?}
    J -->|Yes| K[Every standing section disabled]
    K --> Q{Leaf?}
    Q -->|Yes| F
    Q -->|No| E[Inactive until pruning makes it a leaf]
    K --> L{Nothing destroyed this tick?}
    L -->|Stalled| M[Destroy the most leaf-like anyway]
    M --> G
    I --> N{No sections left?}
    N -->|Yes| O[Ship dead]

Damage is two readings

Health decides when something dies. It cannot decide what the wreck LOOKS like, because a pool is one number for a whole body and the only geometry one number can drive is geometry that changes everywhere at once. So there are two readings taken off a hit, and neither is a look of its own.

DamageLevel(f32) (integrity/erosion.rs) - 0.0 pristine to 1.0 destroyed, derived from the entity’s OWN Health every time health moves. Read it, never write it. Because it is a function of health rather than an accumulator beside it, a body at half health always looks the same amount of wrecked, a reload restores the look for free, and a scripted destroy grades exactly like a shot. Derived per entity and not per aggregate: a skin plate is HealthIsolated, so a stripped plate reads as stripped while the hull under it still reads as untouched.

DamageMarks(Vec<DamageMark>) (integrity/carve.rs) - where the hits LANDED, each a sphere { at, radius } in the LOCAL frame of the body carrying the list. A hit is recorded on the nearest ancestor carrying DamageMarks, never on whatever collider it met. That is what makes a carve continuous: a ship’s plates each derive from the same list, so two plates sharing a boundary compute the same depression at it and a crater crosses the seam instead of stopping at it.

What material costs

DAMAGE_PER_UNIT_VOLUME is 8.0 hit points per cubic world unit. It is the whole coupling between what a weapon costs and what it looks like it did, and it is ABSOLUTE: the same round makes the same hole in a pebble and in a planetoid, because the hole is what the round’s energy is worth. Pricing a crater against the body it landed on is the other design, and it makes a big rock unshootable and a small one vanish on contact.

mark_radius(amount) is therefore (amount / 8.0 * 3 / (2 * pi))^(1/3) - a HEMISPHERE, because a hit lands ON a surface. The shipped kinetic PDC round (4.0 damage) carves 0.62 units.

A mark is priced by what the hit ABSORBED, never by what it asked for (absorbed_by): the first Health at or above the hit clamps it, a node already spent pays nothing, and a chain with no pool at all spends the whole hit in material. Without the clamp a slug that crosses a plate would be charged for the plate and then charged again, in full, for the hull behind it.

The merge, and why a hole follows the aim

Sustained fire has to dig ONE hole rather than two dozen dents, and that job has a SIZE - the width of the hole the last round made - which is why it is capped in world units and not proportionally.

  • MARK_MIN_RADIUS 0.15: below this a sphere cannot reach a boundary sample of the cell it lands in, so it would cost a budget slot and change nothing. Grazing fire should crack, which is the level’s job.
  • MERGE_REACH 4.0: a ceiling expressed as a multiple of the INCOMING bite, never of the grown crater. Testing the grown radius is what let a crater’s own growth widen the area that captured the next hit, which widened it again until one crater ate the whole body.
  • MERGE_MAX 1.0 WORLD unit, converted into the body’s own frame by DamageMarks::add: “the round landed IN the hole the last one made”. This is the cap that actually binds, and it is why the hole follows the aim.
  • MARK_BUDGET 24. At the budget the SMALLEST crater is folded into its own nearest neighbour to free a slot, so nothing is dropped, paid volume is conserved, and the hit that just landed is recorded where it landed.

A blast is the same defect wearing a different hat: it asks for its pressure once per collider it overlaps, and a hull built out of hundreds of them would grow one crater hundreds of times. record_blast_marks sums contributions PER OWNING BODY and cuts them as one crater, capped at the blast’s own radius. apply_blast_damage queues that BEFORE the health triggers, so every body prices against one pre-damage snapshot - the same contract NovaBlast already states for its pressure pass.

What a carve leaves

CarveSpew { entity, at, radius, kind } fires whenever a mark changed a body’s shape, in world space. kind is the weapon class that paid for the carve, and it is what decides the look: spew.rs keys a ShardLook off it, one entry per DamageType. Kinetic and Pierce throw 2 to 7 shards of one fixed size (ShardLook::size, 0.12u) - kinematic, no collider, TempEntity(2.5) - and hold identical values in two SEPARATE entries, so giving a penetrator its own debris is editing a number rather than splitting a branch. Explosive throws nothing: a warhead’s fireball already covers the frames the geometry changes in, the crater is permanent evidence afterwards, and a cut that severs throws real geometry anyway. Shards are born INSIDE the body they came off, so a dynamic body with a collider would spawn interpenetrating and the solver would shove the two apart - a ship kicking itself sideways every time it was shot. An event rather than a direct spawn, so a mod that wants a puff or nothing at all replaces the observer instead of patching the carve.

Real geometry leaves a body only where a carve actually SEVERED it, and only the body being cut knows that. chunk.rs is what a severed piece spawns through; CHUNK_MIN_VOLUME (1.0 cubic unit) is the floor under which a crumb goes out as dust instead. The asteroid is the only body that takes this path - see Scenario engine.

The authored looks

WHICH looks a section wears is content, not engine. A section authors DamageEffects, a list of DamageEffect, and damage_effects.rs turns each variant into exactly one component. Nothing else translates, and no effect system reads the list - each reads only its own component. So the authored list is the CONTENT vocabulary, the components are the RUNTIME vocabulary, and a Rust mod that wants a look nobody authored inserts its own component and touches neither.

variantcomponentwhat it does
CracksDamageCracksFractures the section’s surface, glows through when critical, burns out cold when dead. Replaced SCORCH, a whole-body red tint that fought every authored paint scheme and said nothing about WHERE a section was failing.
SparksDamageSparksThrows sparks, faster the worse it is, past level 0.35. Removes nothing.
PlumeDamagePlumeGuts and flickers a thruster’s exhaust past level 0.35, floored at 25 percent so it never reads as SHUT DOWN. Touches no thrust.

Default is [Cracks] and not the empty list, so unchanged content and third-party mods keep behaving; DamageEffects::none() is the explicit “wears nothing”, because “I want none” and “I did not say” are different statements.

Why cracks are QUANTISED

A section does not carry its own cracked material. Its damage level snaps to one of SECTION_CRACK_BUCKETS steps (damage_cracks.rs), and the mesh swaps to the material shared by every section drawn from the same source material at the same step. Nothing is ever written into a built material, so no section can crack a neighbour that shares its gltf art.

The reason is BINNING. A draw call bins on the material, so a value per section is a bin per section mesh: an eleven-ship wfc_ships gallery held 2,652 section meshes in 2,652 bins of one instance each, the worst case there is for write_binned_instance_buffers, and it cost roughly half the frame rate. It is also why cladding was always free - owning_section stops at a SectionFixture, so 10,936 plates kept the 32 shared materials they were painted with and batched normally. Buckets put sections on the same footing: source materials times buckets, whatever the fleet size, with bucket 0 the pristine step so an undamaged fleet batches as if the effect were not there.

The registry (SectionCracksMaterials) builds a bucket the first time something reaches it and forgets a source material the moment nothing draws from it. Both matter, and both are aimed at a source a MOD mints per instance rather than per prototype: eager buckets would cost it eight materials it never draws, and a registry that never forgot would keep them after it was gone. A pristine base fleet is one bucket.

The same shape, for the exhaust plume

A drive’s flame is quantised the same way and for the same reason (EXHAUST_PLUME_BUCKETS, thruster_section.rs): the throttle snaps to one of 16 steps and the cone SWAPS to the material shared by every nozzle of its shape at that step. Nothing is written into a built material here either.

Read the two together, because the plume is where the pattern earns itself. Cracks change rarely, so a read-before-write guard nearly covers them. A guided torpedo’s throttle genuinely moves EVERY frame, and a hundred can be in the air - so the guard covers nothing, and only sharing does. Assets::get_mut marks an asset modified whether or not the value moves, and a modified material is re-extracted, re-uploaded and has its bind group rebuilt that frame.

What quantising costs is smoothness, and the shader is what makes 16 enough: one bucket step moves the flame tip by 0.0667 local units, and thruster_exhaust.wgsl already jitters that same tip by up to wobble_amp = 0.1 every frame.

The rule the vocabulary is kept honest by: NO SHIP SECTION LOSES GEOMETRY. Every effect here is a material or a particle, and the only thing that changes a ship’s shape is a whole PIECE leaving - a plate shot off, a section destroyed. A Carve effect that cut a real crater out of authored art was built and then removed: reading a solid out of a drawn mesh costs 6-15 ms per mesh, a ship’s marks belong to its root, so one round anywhere on a hull turned every mesh under it into a solid in one frame - 325 meshes, 2.0 seconds. A rock still carves, because a rock’s solid is analytic and its collider IS its mesh.

Typed damage (crates/nova_gameplay/src/damage.rs)

Weapon damage is authored, not emergent from bullet physics, and it is ONE number: there is no resistance table and no per-section multiplier anywhere in the damage path. A projectile carries ProjectileDamage { amount, power, layers, kind } with a DamageType: Kinetic, Pierce, or Explosive. apply_damage is the single point at which any weapon enters the health store, and it is a plain HealthApplyDamage trigger - nothing between the weapon and on_damage reinterprets the number.

A type is a way of TRAVELLING, not a multiplier. That was the point of dropping the table: a round visibly crossing three sections is legible from the cockpit, a 1.5x is not. SectionClass survives the table as the ship computer’s section LABEL (nova_os_ui reads it for codes, glyphs and descriptions); nothing in the damage path branches on it.

Turret bullets are given a near-zero physical mass (NEUTRALIZED_BULLET_MASS) so the impact path’s mass-times-velocity damage (on_impact_collision_deal_damage, integrity/core.rs) is negligible and the authored amount is the only weapon damage. Torpedoes detonate a NovaBlast. damage.rs computes linear falloff from each collider’s world centre. For a target it then walks the centre ray through closer live ship sections: a survivor stops pressure, while a destroyed section transmits 65 percent. All blasts collected in one fixed tick read one pre-damage health snapshot. Health is charged per COLLIDER; the crater is cut once per BODY (see Damage is two readings).

The torpedo fuze

CONTACT_FUZE is 3.0 units to the target’s SKIN, not to its centre of mass. A torpedo holding a locked ENTITY projects its position onto the colliders in that body’s own RigidBodyColliders list. The projection is solid, so a nose already inside the hull reads zero, and reading only the locked body’s list means a torpedo threading a formation cannot fuze on the wrong ship. The three-unit margin clears the torpedo’s own body and both bodies’ motion through the next fixed step. contact_reach(speed, dt) = CONTACT_FUZE.max(speed * dt) widens it further for a fast torpedo.

The old fuze was half the blast radius measured to the centre of mass. It had three consequences and no upside: a torpedo always stood off exactly half a blast radius and so always delivered exactly half its rated pressure; against a rock the crater was cut in vacuum beside the surface, because a rock’s centre is buried under twelve units of solid; and nothing in the game had a contact fuze at all.

That fallback survives for the one case with nothing to touch: a torpedo with a target POSITION but no entity (a scripted launch, or one whose target died in flight) still fuzes at blast.radius * 0.5. A torpedo launched with no lock never receives a target position at all, so it cannot detonate - it flies its lifetime, deals a contact ding and is deleted, and the bay still spends the round.

Note weave_fade is measured off the BLAST RADIUS and not off the fuze: full weave beyond three blast radii, linearly to zero at half a blast radius. The terminal sprint has to start where the corkscrew stops helping, out at point-defense range, not where the warhead finally fires.

Closing speed

Both BULLET types are speed-driven, and the term is computed at the hit, not at the muzzle: closing_speed(round_velocity, target_velocity) projects the same relative velocity on_impact_collision_deal_damage uses onto the round’s own line of flight (projecting onto the line BETWEEN the bodies is unusable - at contact they are touching, so that direction is noise). Both curves are the speed ratio against REFERENCE_CLOSING_SPEED (100 u/s, the shipped PDC’s muzzle_speed), clamped:

  • kinetic_damage_multiplier scales what a hit DEALS, clamped to [0.25, 2.0];
  • pierce_power_multiplier scales how far the round GETS - it divides what a layer costs - clamped to [0.5, 3.0].

Linear, not the ram model’s own curve: impact_damage is impulse plus absorbed energy, and at bullet speeds the quadratic energy half reads ~3.9x at twice the reference, which would turn a ~400 DPS PDC into ~1600. Both read exactly 1.0 at the reference, so authored bullet_damage values keep the feel they were tuned for. Speed scaling is deliberately NOT in apply_damage: a ram already carries its velocity in the amount, and a blast has no line of flight.

The travel rule

pierce_remainder (damage.rs) is the whole rule, one branch per type; spend_piercing_damage deals hit_bite through apply_damage and then calls it.

  • KINETIC spends its DAMAGE. amount doubles as the budget: a hit that fails to destroy the target has by definition put the whole bite into it, so the round dies; a hit that destroys it costs only the health that was there, priced back through the speed curve that scaled the bite, and the rest flies on. A slug can never deal more in total than it was fired with.
  • PIERCE spends POWER, never damage. amount is flat - the same bite into every layer, no speed term and no decay with depth. Crossing a layer costs that layer’s Health.max divided by pierce_power_multiplier. MAX, not remaining, for two reasons: light plating stays nearly free while a heavy block is expensive (the spaced-armour intuition), and softening a section with other fire cannot open a cheaper hole through it. A rake’s TOTAL damage therefore exceeds what it was fired with, which is intended. PIERCE_BASE_POWER (300 hp of thickness) is the budget and MAX_PIERCE_LAYERS (6) the backstop under it, because cheap plating alone would not bound the chain.

A target with no Health on the hit collider (an asteroid, a planetoid, a pool that lives on an ancestor) has no thickness to price and nothing provably destroyed, so it is a wall to both types at any speed. Nothing in the rule knows what it hit, so destructible cover needs no special case. Torpedoes do not use it - they detonate on a contact fuze.

The carve reads the same absent pool and draws the OPPOSITE conclusion, and the pair is easy to misremember. absorbed_by walks the same ChildOf chain: no pool anywhere up it means the whole hit is spent in MATERIAL. That is the asteroid rule, not a fallback - a rock’s remaining solid is its durability, and clamping against a pool it does not have would stop rocks carving at all.

One avian trap the hit callsite has to handle: CollisionStart is raised once per EVENT-ENABLED collider, so a contact with events on both sides arrives twice with collider1/collider2 swapped. An asymmetric rule must act on one ordering only (resolve_bullet_hit keys on the round being named first; on_nova_blast_collision on the blast being body1), or it pays out twice per contact. A symmetric rule - ram damage - wants both.

Ammo

  • SectionAmmo (sections/ammo.rs): optional magazine on a weapon section. Absent = unlimited fire; ammo_capacity in the turret/torpedo config opts in. The player infinite_ammo flag builds that ship’s weapons without magazines, but only under the debug feature: a shipped build logs a warning and keeps the authored magazines, so unlimited fire is a dev cheat, never a player state.
  • SectionReload (sections/ammo.rs): optional idle batch reload on a magazine, from the turret/torpedo config reload: Some((delay, amount)). Every successful shot resets progress; every completed quiet delay restores one batch until full. Fire runs before tick_section_reload in FixedUpdate so a shot wins an exact completion tick. Unlimited weapons never reload. The HUD reads progress() and incoming_rounds() to pulse only the next batch.
  • LoadedBullet (sections/turret_section/mod.rs): the turret’s loaded-round slot (damage type + amount), seeded from the config. Fired bullets and the HUD ammo readout colors read this slot, so swapping ammo types is one component write.
  • DefaultProjectileRender (sections/turret_section/render.rs): the built-in round art, ONE mesh + material per DamageType, built in FromWorld. The render observer reads the round’s own ProjectileDamage.kind and hands out clones, because a turret’s authored projectile_render_mesh is per-TURRET while the fired type comes from LoadedBullet at runtime. Every shipped turret leaves that field None, so this IS the shipped path at 100 rounds/s per muzzle: it must never allocate per shot, and default_projectile_render_allocates_no_assets_per_shot pins that. Its meshes come from sections::nose_cone_mesh (a cylinder and a cone, merged), which the torpedo warhead’s DefaultTorpedoRender shares. The warhead colours ITS copy of that mesh with a StandardMaterial held per TINT, so a salvo of one ordnance type is one material however many tubes fired it - and so its crack buckets, which key on the source, are shared by the salvo too. Note a cracked material is an ExtendedMaterial rather than a StandardMaterial, which is why SectionCracks holds a STRONG handle to its source: once the mesh’s MeshMaterial3d<StandardMaterial> has been swapped away that handle is the only thing a later bucket can be built from, and a source that cannot be dropped cannot have its AssetId reissued to something else.

Find it in the code

  • Section kinds and base config: SectionKind, BaseSectionConfig - crates/nova_ship/src/sections/base_section.rs.
  • Spawn path: insert_spaceship_sections - crates/nova_scenario/src/objects/spaceship.rs.
  • Integrity core: NovaIntegrityPlugin - crates/nova_gameplay/src/integrity/mod.rs; graph, sever and collapse: ShipIntegrityPlugin - crates/nova_ship/src/sections/integrity.rs.
  • Typed damage and the travel rule: DamageType, apply_damage, pierce_remainder - crates/nova_gameplay/src/damage.rs.
  • The two damage readings: DamageLevel - crates/nova_gameplay/src/integrity/erosion.rs; DamageMarks, DAMAGE_PER_UNIT_VOLUME, mark_radius, record_blast_marks - crates/nova_gameplay/src/integrity/carve.rs.
  • Carve leftovers: CarveSpew - crates/nova_gameplay/src/integrity/spew.rs; spawn_carved_chunk, CHUNK_MIN_VOLUME - crates/nova_gameplay/src/integrity/chunk.rs.
  • How a body comes apart: detach_destroyed_body, DetachedPieceMarker - crates/nova_gameplay/src/integrity/explode.rs.
  • Authored damage looks: DamageEffect, fit_damage_effects - crates/nova_ship/src/sections/damage_effects.rs, with one module per look in damage_cracks.rs, damage_sparks.rs and damage_plume.rs.
  • Derived skin and styles: ShipSkinPlugin - crates/nova_ship/src/sections/shell_skin.rs; ShipStyleConfig - crates/nova_ship/src/sections/skin_style.rs.
  • API detail: cargo doc --open -p nova_ship (integrity and damage: -p nova_gameplay).

Scenario / modding system

How-to companions: Create your first scenario to write one in RON with existing primitives, or Extend the scenario engine to add new event, filter, action, or object kinds in Rust. The exhaustive construct-by-construct catalog for authors is the modding reference; this page is the engine’s internals.

crates/nova_scenario is the data-driven scenario engine, the layer for missions, objectives, and reactive world behavior. A scenario is a list of event handlers; each pairs an event with filters (all must pass) and actions (run in order). It builds on GameEventsPlugin/EventWorld from nova_events; nova_scenario provides NovaEventWorld and the enums below.

Three surfaces write that list: a RON file, a Rust builder under nova_authoring, and the editor’s EVENTS mode, which lifts a handler into nodes you select and inspect - a condition included, drawn as a page of its own - and lowers them back on save (crates/nova_editor/src/event.rs). All three produce the same ScenarioEventConfig, so nothing below cares which one wrote it. The editor reads its TOOLTIPS off these configs too: a field’s doc comment is what the panel says about it, through bevy/reflect_documentation.

Scenario structure

  • ScenarioConfig - id, name, description, cubemap (skybox), skybox_brightness (lux, defaults to DEFAULT_SKYBOX_BRIGHTNESS), events.
  • ScenarioEventConfig - one handler: label (optional, what the handler is for in the author’s words - the editor’s tree reads it beside the trigger), name: EventConfig, once, filters, actions. once retires the handler the first time its filters PASS (not the first time its event fires): the loader-spawned entity is despawned, and ScenarioEventConfig::build_handler is the single place a config becomes a runtime handler, so the loader and every headless rig honour the same fields.
  • GameScenarios(HashMap<ScenarioId, ScenarioConfig>) - all known scenarios, populated by nova_assets (ready at GameAssetsStates::Loaded).
  • CampaignConfig - id, name, scenarios (ordered member scenario ids, hidden ones allowed); a first-class content kind (Campaign((..))).
  • GameCampaigns(HashMap<CampaignId, CampaignConfig>) - all known campaigns, the ordered campaign->scenario mapping the Scenarios picker groups/launches by, populated by nova_assets alongside GameScenarios.
  • CurrentScenario(Option<ScenarioConfig>) - the loaded scenario, if any. The scenario_is_live run condition gates the ship input/section sets on it.

Loading / unloading (loader/)

  • LoadScenario(ScenarioConfig) - trigger to load: look one up in GameScenarios, commands.trigger(LoadScenario(cfg.clone())) (see examples/systems/system_scenario_grammar.rs). Load tears down the previous scenario, spawns the camera, input context, one handler per event, fires OnStart. No engine light: a scene is lit by the Light objects it authors, and one that authors none renders black.
  • ScenarioLoaded - fired after a load; carries scenario_id, handler_count, object_count for smoke-test assertions.
  • UnloadScenario - tears everything down and clears CurrentScenario.
  • ScenarioScopedMarker - any entity carrying it is despawned (recursively) on load/unload. Teardown also runs NovaEventWorld::clear() and clears all HUD hint emphasis.

Cleanup contract: every entity spawned while a scenario is live must (1) carry ScenarioScopedMarker (all scenario objects do), (2) carry a lifetime component - register_scenario_scoping tags every transient with ScenarioScopedMarker the moment it declares one (TempEntity for countdown transients, SfxAudioMarker for SFX one-shots), so projectiles, blasts, debris, blast cosmetics and still-playing sounds all die with their scenario, (3) be a child of a scoped entity, or (4) be torn down by a Remove observer (the HUDs on PlayerSpaceshipMarker). Anything else leaks.

A TempEntity does NOT clean itself up reliably: its countdown runs on Time<Virtual>, which the pause menu and the outcome overlay STOP. A torpedo blast that fuzes on the frame the player dies therefore outlives the whole Defeat overlay and survives Retry, arriving in the reloaded scenario with its damage intact. Scoping ON the lifetime component, rather than trusting the lifetime to run out, is what closes that.

The load warms every hull the scenario can spawn

A section’s glTF is resolved by the render observer that builds its mesh child, so the FIRST ship wearing a hull paid for that hull’s art. A hull no OnStart event spawns is therefore cold when its beat arrives: final_tally’s flagship and both of menu_duel’s corvettes appeared in placeholder art and dressed themselves a moment later.

preload::scenario_render_meshes walks the loaded config for SpawnScenarioObject and ScatterObjects actions, resolves each ship’s ShipSource and every section’s SectionSource against the two catalogs, and collects the render meshes. That walk is possible at all because a spawn action carries its object’s WHOLE config inline rather than an id looked up at spawn time, so what a scenario can spawn is readable before it spawns anything.

Three parts make it work:

  • AssetRef::resolve is idempotent, so the spawn site is unchanged - it asks the AssetServer for the same path and gets back a handle that is already warm.
  • ScenarioPreload HOLDS the handles for the scenario’s lifetime. Without a strong handle bevy frees the mesh again long before the mid-mission spawn.
  • The load WAITS: scenario_has_settled and the LOADING panel both hold while the warm-up is pending, bounded by its own deadline so a missing or broken mesh cannot hang the load. A failed mesh counts as settled and is named in a warning; the section spawns in placeholder art, exactly as it would have.

Ships are the only object kind involved. A beacon and a salvage crate build primitives, an asteroid meshes itself on a worker, and a light and an anchor have no mesh at all. The warm-up is also registered only when NovaScenarioPlugin::render is set: a headless rig builds no mesh children, so there is nothing to warm and nothing to wait for.

The vocabulary, and who documents it

Three closed enums are the whole authored language, one dispatch match each:

EnumFileTrait it dispatches toCreator reference
EventConfigevents.rsEventHandler<NovaEventWorld> (via From)/create/events/
EventFilterConfigfilters.rsEventFilter<NovaEventWorld>/create/filters/
EventActionConfigactions/mod.rsEventAction<NovaEventWorld>/create/actions/

The construct-by-construct catalog is /create/, not this page. Every event’s exact firing condition, every filter field, every action’s RON and defaults are the authored CONTRACT and have to be exact; a second copy here would be nobody’s job to update, and a reader would have no way to tell which one was true. This chapter covers what the enums do not show.

A config is held together by STRINGS, and to the type system every one of them is a String: SetAllegiance names a ship, TimerCancel names a timer, NextScenario names a scenario. Names (names.rs) puts the difference on the field as a reflect attribute - #[reflect(@Names::Object)] and its NewObject / Variable / Timer / Objective / Scenario siblings - so anything walking a config by reflection can offer the ids in scope and mark one that resolves against nothing. The editor’s inspector is the reader that exists; a surface keeping its own list of which field names what goes stale the day an action is added, which is the failure the attribute removes. A new string field that refers to something declares what, or it is a blank box.

Events carry identity, not payload-by-position: entities wear EntityId(String) and EntityTypeName(String), and every PAIR event has the same filter shape - a subject id plus an other_id / other_type_name. Which entity is the subject is per-event (area against body, well against ship, target against locker), which is why the filter is one struct rather than one per event. Lock and orbit lifecycle events are one-shot EDGES with no hidden timer behind them: a target switch queues end-old then start-new, and a scenario that needs a continuous hold composes the edges with a keyed timer.

Filters read and never mutate; they take &NovaEventWorld and the fired GameEventInfo and return a bool, and every filter on a handler must pass. Actions take &mut NovaEventWorld and run in order. Neither touches the Bevy World directly - see the seam below.

flowchart LR
  Event["Event fires"] --> Filters["Filters gate"]
  Filters -->|all pass| Actions["Actions run"]
  Filters -->|any fail| Stop["No-op"]
  Actions --> Vars["Mutate variables"]
  Actions --> World["Mutate event world"]
  Actions --> Objects["Spawn / affect objects"]
  Filters -->|"all pass + once"| Retire["Handler despawns"]

A once handler retires on the PASS edge, so a refused event leaves it live and a beat waiting on a condition keeps every later chance at it. Retirement is a despawn, which maintain_handler_index turns into an index removal before the next dispatch; the dispatcher also holds a pass-local spent set, because one drain pass walks the whole queue against a single index snapshot and two queued events of the same name would otherwise reach the same handler twice.

What an action does that its RON cannot show

Most actions are a straight write into NovaEventWorld. Four are not, and the difference is engine behaviour rather than authored syntax:

  • Outcome is not just an overlay. Setting one puts the app into PauseStates::Paused for as long as it is set, so physics, AI, weapons and timers stop behind the banner while the overlay’s own buttons stay live. Scenario teardown clears it, which also releases the pause.
  • SetCamera has to WIN every frame. It drops WASDCameraController and pins a ScriptedCameraPose that is re-enforced in CameraAuthoritySystems::Override, because both camera controllers keep writing the camera Transform otherwise - the same swap the player-ship-spawn observer does.
  • SetSkybox installs DEFERRED. The skybox setup observer reads the image immediately and would panic on a handle that has not loaded, so the action only tags the scenario camera with PendingSkyboxSwap and apply_pending_skybox_swaps inserts the real SkyboxConfig once the image is in. A failed load warns and leaves the sky alone.
  • NextScenario with linger: true does not switch on its own: it parks the request until something clears the flag. That something is the scenario-advance input or an outcome-overlay button, which is how Continue and Retry ride a queued switch.

HintEmphasisSet is worth one line for the same reason: the keybind dock hides verbs the ship cannot use, so an emphasis on an unavailable verb REVEALS its chip in the dim band rather than doing nothing. That is how a tutorial points at a key before it lights up.

Actions fan out to one submodule per family beside actions/mod.rs - flow, mission, sequence, ship, spawn, timer, view - and adding one is Extend the scenario engine.

Sequence keeps its cursor in the engine

Sequence is the one action whose state does not live in the action. A SequenceActionConfig is an authored LITERAL key plus an ordered list of steps; running it calls NovaEventWorld::start_sequence, which files a SequenceRun - key, steps, cursor, the time the step became current - in the event world beside the keyed timers. The config is immutable and shared (Arc<Vec<SequenceStepConfig>>), so the same chain can be authored once and started from several handlers.

The cursor CANNOT live in the action: handlers are dispatched from an index snapshot and an action config is read-only during a pass, so a step counter held there would be per-fire, not per-run. Keying it by an authored literal also keeps content lint whole-program - no id in authored content is computed, so the linter still resolves every reference statically.

Three pieces move a chain forward:

  • advance_scenario_sequences runs in Update, chained after sample_scenario_queries and before tick_scenario_timers and fire_on_update. It drains take_ready_sequence_step and runs the actions the step returns. Queries are sampled first so a gate filter reads this frame’s values.
  • A step’s until gate is a real handler. sequence_gate_handlers walks a handler’s actions and spawns one extra EventHandler per gated step, carrying a private SequenceGateAction { key, step }. It is inert unless the cursor stands on exactly that step, so a gate cannot open a chain it does not belong to, and the gate opens the run rather than running the beat. That costs one frame of latency between the gate event and the beat.
  • take_ready_sequence_step stamps since = now on the step it hands back, so ONE clock jump delivers at most one step of any one chain. Steps with no delay still collapse into a single pass, because the driver loops.

Both waits on a step apply together, and the semantics are WAIT, never SKIP. That makes a shut gate a soft-lock, which is why a gated step carries a deadline: expiry stops the run and logs an error! naming the key, the step and the event it waited for. start_sequence holds the other loud half - a restart on a live key is refused and logged, because one key is one cursor.

Because a step’s action list is a FRAME of its own, four walkers had to learn to recurse into it: inline_queries, object_count, the lint’s collect_declared / check_action, and the per-event spawn-id pass. The shared helpers are EventActionConfig::walk and ScenarioEventConfig::action_groups, which returns a handler’s own actions plus one group per Sequence step it starts, however deeply nested. Any new rule that reasons about “one frame” reads action_groups, not actions.

Variables and the event world (world.rs, variables.rs)

NovaEventWorld holds the scenario state: variables, objectives, next_scenario, and a queue of deferred command closures. Filters and actions mutate only this resource, never the Bevy World; world access goes through world.push_command(|commands| ...). Each frame state_to_world_system syncs objectives into GameObjectives (write-on-diff), runs a queued non-lingering NextScenario switch, and drains the command queue.

The drain is CHUNKED, not one flush. A chapter’s OnStart queues a closure per object, and applying them together cost one ~300 ms frame - a frame nothing can be drawn on, so the LOADING panel froze on the exact frames it exists to cover. Commands are applied one at a time until SPAWN_DRAIN_BUDGET (3 ms) of the frame is spent, so a big scene arrives over several frames and a slower machine takes MORE FRAMES rather than a longer one. One command per apply is also what keeps each object atomic: a ship’s sections all land inside one apply, so the Added<SectionLinkPoints> batch the integrity graph and the derived skin key off is complete the first time they see it.

While commands remain, the scenario is SETTLING (EventWorld::is_settling). The dispatcher holds every handler, and a handler that queues world work stops the current pass, so no handler ever runs against a world known to be incomplete. The scenario clock stops, keyed timers do not expire, watches are not sampled, the OnUpdate pulse does not fire, and the LOADING panel stays up. The world is not yet LIVE, rather than briefly inconsistent. Held events are not dropped: they dispatch in order on the frame the world goes live.

scenario_has_settled - the run condition the clock and the pulse read - holds for one more reason: the glTF warm-up above. Dispatch is not, so OnStart still fires and the scene still builds while the art arrives; what waits is the scenario CLOCK, so no mission time passes behind a panel the player cannot see past.

Variables are typed literals (String, Number, Boolean) with a small expression tree: VariableExpressionNode (add/subtract), VariableTermNode (multiply/divide), VariableFactorNode (literal/name/parens); VariableConditionNode (less/greater/equal) yields booleans for filters.

The tree has a TEXT form (syntax.rs): Display renders it as scenario.elapsed > 90 and FromStr parses that back. The authored form is still the RON nest - the text is what one editor row can hold, and what a tree row reads as when it is shut. Round trip is the contract in both directions, parse-of-render and render-of-parse, which is what lets a panel own a condition without a save quietly rewriting it. The syntax spells the grammar and nothing more: no operator the crate cannot evaluate, and a - b - c parses rightward because Subtract(Term, Expression) nests that way.

The editor takes a condition APART along the same grammar: each operator is one document node with its two sides as children, and a leaf holds whatever fits one row of the text form. Those nodes are NOT part of the tree - the rail shows the filter and stops - and the panel draws the whole condition as a page instead, one row per node, each writing to its own entity. Parens is dropped on the way in - the nesting says what the brackets said - and put back on the way out wherever the position needs it, so a sum under a product lowers as (a + b) * 2. What a switched operator cannot hold it does not keep: an operand a value has no place for is dropped rather than left hanging where no row would show it.

Two clocks pace a transition

/create/actions/ documents the three gears a scenario switch has - hard cut, delayed cut and modal hold. The engine fact underneath them is that they do not all run on the same clock, which is the only part that is not obvious from the RON:

  • A NextScenario delay ticks on Time<Virtual>, the pause-frozen scenario clock, so a player who pauses holds the cut.
  • An Outcome’s auto_advance_secs cannot, because the overlay it belongs to STOPS Time<Virtual>. It runs on the wall clock instead. A timed banner that used the scenario clock would never fire.

Anything that has to keep counting behind a frozen overlay is in the same position and has the same answer.

Story pacing is a QUEUE, not a slot

StoryMessage writes into a bottom-left comms stack rather than a latest-wins line: arrival order, a bounded number of cards visible with a bounded backlog behind them, oldest dropped when the backlog overflows, and the whole log kept in the feed. That is why a burst of lines is survivable - but one line per beat is still the style, and the queue is the safety net.

Two consequences for anything that fires story lines:

  • The stack is a HUD surface nova_scenario reaches up into. The dwell limits and the card budget live with the HUD, not with the action, which is one of the two edges Architecture calls out as running the “wrong” way on purpose.
  • It is scenario-scoped, so teardown clears the log and nothing bleeds into the next scenario or the menu - the same rule as objectives, HUD readouts and hint emphasis.

Field-level detail (dwell and its clamp, icon, the two lint warnings) is the authored contract and lives in /create/actions/.

Typed queries and watched variables

The engine exposes read-only world state through typed QUERIES, and a scenario samples one into a WATCHED variable. /create/expressions/ is the authored reference for both - the query kinds, their properties, and the beat and wave shapes built on them. Three mechanism facts sit under it:

  • The watch owns the name. A watched variable is written by the sampler each live, unpaused update, so VariableSet on that name is REJECTED while ordinary reads - Name("...") expressions, HudReadout - work normally. A variable is therefore either authored or watched, never both.
  • The clock is not created by exposing it. nova_scenario keeps an internal scenario clock for keyed timers whether or not any content asks; Scenario(Elapsed) only publishes it. Both stop together under pause and behind the outcome overlay, and both restart on a retry - which is what makes a run timer show the FINAL time behind a Victory banner instead of counting on under it.
  • Entity is strict-single, and unavailability propagates. Zero matches, several matches, or a match missing the property leaves the query unavailable, and an expression over an unavailable value fails CLOSED. Missing is not zero, which is the difference between a gate that never opens and a gate that opens immediately.
  • The entity sampler runs only if something reads it. Sampling walks every EntityId in the world and allocates per match, so its cost scales with the WORLD - a duel carries about 1,800 ids, most of them ship sections - and not with the scenario. ScenarioConfig::reads_an_entity_query decides at load, over the watches AND the inline expression factors together, because an inline query has to be answerable the first time its action runs. Anything that adds a new place an expression can be authored has to be added to ScenarioConfig::inline_queries with it.

Watches freeze under pause and clear at teardown, like every other piece of scenario-scoped state.

The OnUpdate pulse SLEEPS (loader/wake.rs)

fire_on_update used to queue an event every frame, and the dispatcher then walked the whole bucket re-evaluating filters that could not have changed their answer. It now runs behind a gate, and a scenario with nothing to react to queues nothing. The rigidbody analogy is exact: the scenario sleeps until something wakes it.

Two things wake it, both derived at load by wake.rs and held in a WakeProfile:

  • A write. NovaEventWorld::insert_variable is the single write path, so every write joins a dirty set; the pulse fires when that set meets a variable an OnUpdate filter reads. What an OnUpdate handler WRITES joins what it reads, or a counter it advances itself would freeze the moment nothing else in the scenario writes.
  • A scheduled time. GreaterThan(scenario_elapsed, 95.0) is known at load, so the crossing is scheduled rather than polled. Only a bare clock read against a literal schedules; scenario_elapsed * 2 is arithmetic.

Three properties are worth knowing before reading the code:

  • Nothing is authored. The filters already declare all of it. An authored wake list would be a second source of truth that can disagree with them - name two variables, read three, and the handler silently never fires on the third. /create/ does not grow.
  • The default is EveryFrame, and it is the fail-safe. A filterless OnUpdate, an Entity or Timer filter, an inline Query(..), a watch fed by a per-frame sample, or a clock compared against a variable all fall back to the old behaviour. A case the analyser does not understand is SLOW, never wrong.
  • The decision is per SCENARIO, not per handler. The gate either queues the event or does not; per-handler gating would mean changing the nova_events dispatcher. One per-frame handler therefore holds the whole scenario awake, and that is sometimes correct - a speed ladder and a HUD countdown are both continuous questions.

A Sequence step’s until gate is a real handler the loader spawns, so it is scanned with the authored ones. A gate waiting on OnUpdate that was not a reason to wake would stall its chain forever.

Measured on a headless run, as the share of frames that queue the event:

scenariopulses / frameswhy
final_tally308 / 18300value-gated milestones
broadside355 / 13500value-gated milestones
shakedown_run425 / 16800value-gated milestones
ledger_ch1350 / 22500milestones plus four scheduled lines
ledger_ch3every framea player_speed ladder, correctly polling
lifelineevery framerecomputes a HUD countdown per frame

Scenario patterns

The engine holds three facts for content - once, keyed timers, and a Sequence cursor - and everything past those is one numeric variable plus Expression filters. Two variable idioms recur; both are worked end to end in the Gauntlet worked example below. Excerpts here are verbatim from webmods/gauntlet/gauntlet.content.ron.

once and Sequence are what a variable is NOT for. A flag whose only reader is its own filter - seeded in OnStart, read by one gate, written by that gate’s own action - is the engine’s fact, and once carries it. A step counter whose only job is to keep paced beats in order is the engine’s fact too, and a Sequence cursor carries it. Keep a variable where another handler reads it: an ORDERING counter like gate below, driven by where the PLAYER is, or a signal like “the convoy lost a ship”.

The gate-counter ordering pattern

A single numeric variable acts as a state machine that enforces ORDERED entry: each stage’s handler is guarded on the counter holding that stage’s value, and the last thing the handler does is bump the counter to arm the NEXT stage only. An event that arrives out of order finds the counter on a different value and does nothing.

Gauntlet’s variable is gate (the index of the gate to thread next, 1..=7). OnStart seeds it:

VariableSet((
    key: "gate",
    expression: Term(Factor(Literal(Number(1.0)))),
)),

Each gate’s OnEnter handler carries two filters - an Entity filter that matches the area/body, and an Expression filter that pins the counter - so only the in-order entry fires:

(
    name: OnEnter,
    filters: [
        Entity((
            id: Some("gauntlet_gate_1"),
            other_id: Some("player_spaceship"),
        )),
        Expression((Equal(
            Term(Factor(Name("gate"))),
            Term(Factor(Literal(Number(1.0)))),
        ))),
    ],
    actions: [
        ObjectiveComplete((id: "gate_1")),
        VariableSet((
            key: "gate",
            expression: Term(Factor(Literal(Number(2.0)))),
        )),
        // ... re-point the objective marker at gate 2 ...
    ],
),

Because gate 2’s handler filters Equal(gate, 2.0), flying through gate 3 early - or back through gate 1 again - matches no live handler and is inert. The scenario_gate_course rig’s gates_advance_only_in_order_and_only_for_the_named_ship test pins exactly this on a synthetic course: an out-of-order entry does not advance gate.

Use it whenever stages must be visited in sequence (a gate run, a guided tour, a tutorial’s step chain). The base shakedown_run starter uses the same idiom with a beat counter; see Built-in scenarios.

The act-gating pattern

A post-decision event can otherwise flip an already-decided outcome: in Gauntlet a wreck normally means Defeat, but a wreck that drifts into a rock AFTER the win must not overwrite the earned Victory. The fix is to guard the Defeat handler on the same counter, past a terminal value the winning handler sets.

The FINISH handler bumps gate one past the last real gate (to 8.0, the terminal done-state) as it declares Victory:

// Terminal: bump past the last gate so no OnEnter re-fires
// AND the player-death Defeat handler (gated gate < 8) can
// never flip an earned Victory to Defeat.
VariableSet((
    key: "gate",
    expression: Term(Factor(Literal(Number(8.0)))),
)),
Outcome((
    outcome: Victory,
    message: Some("You ran the gauntlet clean. ..."),
)),

The OnDestroyed Defeat handler is then guarded gate < 8, so a death blast after the course is finished declares nothing:

(
    name: OnDestroyed,
    filters: [
        Entity((
            id: Some("player_spaceship"),
        )),
        Expression((LessThan(
            Term(Factor(Name("gate"))),
            Term(Factor(Literal(Number(8.0)))),
        ))),
    ],
    actions: [
        Outcome((
            outcome: Defeat,
            message: Some("You wore your hull down to nothing ..."),
        )),
        NextScenario((
            scenario_id: "gauntlet_run",
            linger: true,
        )),
    ],
),

The rig’s a_wreck_after_the_finish_declares_nothing test seeds gate to 8.0, fires the death, and asserts no outcome and no retry (its sibling a_wreck_before_the_finish_declares_defeat_with_a_retry pins the other half). Use this whenever a lethal event can still fire after the scenario is decided (a boss’s death explosion catching the player, a wreck sliding into a hazard): pick a terminal counter value the winning handler sets, and guard every outcome handler against it.

The Gauntlet worked example

webmods/gauntlet is the reference implementation for both patterns. Trace it end to end:

  • The content file webmods/gauntlet/gauntlet.content.ron - one NEW scenario, no base overrides; the gate-counter and act-gating idioms above live here with header comments explaining the two geometric invariants.
  • The time-trial wiring: OnStart fires one HudReadout on scenario_elapsed (Time format) for a live mm:ss.s clock, and seeds a crash counter that hazard-zone OnEnter handlers bump on each graze. Crossing FINISH bumps gate to its terminal 8.0, then TWO crash-gated Outcome(Victory) handlers fire in the same pulse (exactly one matches): crash == 0 earns the CLEAN RUN banner, crash > 0 the plain finish. The final time is shown by the frozen readout behind the banner (the clock stops on the outcome pause), so the banner text only has to vary the clean-run line - no message interpolation needed.
  • The test rig crates/nova_assets/tests/scenario_gate_course.rs - authors a synthetic course as a RON string, drives the real handlers, and pins the ordered-gate sequencing, the repeatable penalty zone, the two counter-keyed win banners, the act-gating and the readout wiring. Run it with cargo test -p nova_assets --test scenario_gate_course. Geometry invariants (gate areas do not overlap; the racing line clears every rock’s worst-case body past ASTEROID_GEOMETRIC_FACTOR_MAX) are a CONTENT concern, checked per bundle by content lint.
  • The first-scenario guide’s completed flow is the gentler, single-counter cousin of the gate-counter pattern.

Scenario objects (objects/, ScenarioObjectKind)

One module per kind under objects/, one arm in the ScenarioObjectKind match in actions/spawn.rs. The authored fields of each kind - and every trap in them - are /create/objects/; what follows is what the modules have in common.

All share BaseScenarioObjectConfig (id, name, position, rotation) and spawn scoped entities via base_scenario_object, which deliberately carries NO body: each kind declares its own RigidBody, and the asteroid alone opts into Dynamic + TransformInterpolation. A carved rock also emits new dynamic bodies at runtime - every piece a crater severs (CarvedChunkMarker, integrity/chunk.rs) - so the spawn kinds are not the whole population of a live scene.

Three engine facts the object configs do not show:

  • Nothing supplies a light. Light is an ordinary spawned kind (objects/light.rs) and the engine adds none of its own, so a scenario that authors no Light renders black. This catches every new backdrop.
  • A rock has no health field, and that is not an omission. What an asteroid is made of IS its durability; the mechanism is below. Its mass is the body’s mu and sets both the pull a = mu / r^2 and the sphere of influence - the distance where that decays to GravitySettings::soi_cutoff_accel - so a well is authored by the SOI it should have, mu = soi_cutoff_accel * soi^2. An Anchor publishes the same GravityWell from an AUTHORED radius instead of a mesh-derived one, which is what makes it deterministic where a carved rock is not.
  • Ship section geometry is LINTED, not clamped. Overlapping unit-cube cells and a turret or torpedo mount whose base (local -Y under its rotation) faces an empty neighbour cell are content lint ERRORS, so a bad hull fails authoring rather than spawning wrong. See Ship sections internals.

How an asteroid carves

An asteroid is the one body in the game with nothing to hide behind. A ship carves through its cladding and stops at the structure underneath, because a plate is one cell thick and the hull it is bolted to is a glTF model nothing can cut. A rock is solid all the way down, so a carve here goes as deep as the hit deserves.

The field IS the rock. pristine_field(seed, radius) is the only description of an asteroid’s shape. pristine_rock_mesh is that field meshed, and it is what the spawn path draws and collides with; the reseed on the first hit calls the same function with the same seed and gets the same grid back. It used to be two shapes - a subdivided octahedron displaced by the noise for the shipped mesh, and a field for the carved one. They agreed to within a cell, which is not the same as agreeing: the first hit moved the silhouette and changed the size of every facet, and that pop was visible on a rock the shot had barely scratched.

The grid is kept only while it is needed. 140 KB on an arena rock and 275 KB on the biggest the cap allows, and a scenario scatters a hundred rocks most of which are never touched - so the spawn path meshes the field and DROPS it. The first hit pays to build it again (tens to hundreds of thousands of noise samples); from then on nothing resamples.

The cost model. FIELD_CELL_WORLD is 0.5 WORLD units, and the cell COUNT is derived from it - the opposite way round from how this started. A crater is a world-sized thing (a 4-damage PDC round carves 0.62 units whatever it lands on), so a grid whose cells grew with the rock could not draw that round’s hole on anything big: 32 cells across a radius-3 rock is a 1.02 unit cell, four times the round being fired at it. Coarseness is the ART, not a resolution knob - a finer grid only makes a smoother rock. FIELD_RESOLUTION_MIN is 16 and FIELD_RESOLUTION_MAX is 40; the cap BINDS above about radius 1.8, and what it costs there is the cell (a radius-3 rock is gridded at 0.82 units, so a PDC round on one is under a cell and only sustained fire - whose mark GROWS where it is held - opens a hole). 41^3 corners is 275 KB per carved rock, paid only by rocks that are hit. FIELD_MARGIN is 1.08, only just over 1 because carving never ADDS material.

Nothing happens in the frame that asked for it. The seed and the whole carve

  • split, surface nets, collider, and the geometry of every piece the cut freed - run on the async compute pool, at most one job in flight per rock, and the rock keeps drawing the surface it already had until one lands. What the main thread pays is the sphere subtraction the mark itself reaches, and then PLACEMENT: one transform and one spawn per piece. A remesh also waits until the grid actually loses a cell (a quantized meshed_volume compare), so sub-cell hits accumulate in the field without paying for connectivity, surface generation or a collider rebuild.

CarveApplyReport is what holds that line, and it counts GRIDS rather than milliseconds because a count reads the same on every box. One grid per rock is its own new solid; one per PIECE means the main thread is holding a quarter-megabyte field to ask it questions each of which is a scan of all of it. It used to. Three rocks landing in one frame with five pieces between them measured 17.5 ms in that frame against a 0.02 ms median; the same frame with the pieces built on the worker measures 0.03 ms. bug_carve_apply is the range that holds it there.

Severing and death. SignedField::split_off_islands (crates/nova_gameplay/src/mesh/field.rs) hands back whatever the cut freed. A piece past CHUNK_MIN_VOLUME becomes a rigid body of its own, meshed by the SAME surface nets the rock is, carrying v + omega x r; anything smaller is announced as a carve and goes out as dust. Both decisions are the worker’s: the threshold is a WORLD volume and the grid is in the rock’s own unit space, so the job is told the scale it cannot see. When the remaining solid falls under CHUNK_MIN_VOLUME, or the surface comes back empty, the rock inserts IntegrityDestroyMarker, fires OnDestroyedEvent itself and despawns its root - so a rock’s OnDestroyed comes from nova_scenario, not from nova_gameplay’s integrity stack.

BodyRadius only ever SHRINKS. Everything sized off a rock’s surface - standoff distances, orbit clearances, the sphere of influence - was authored against the pristine radius, so shrinking keeps every one of those valid and growing would silently invalidate them. The collider density rides along unchanged, so avian re-derives mass from the volume that is left: a carved rock is a lighter rock.

The surface is sampled by POSITION. AsteroidSurfaceMaterial / RockHeight are triplanar in the body’s own local space, so a carved rock wears exactly the surface an uncarved one does and there is no per-triangle quilting. It is also why a severed piece must not inherit that material blindly - a piece is a new body with a new origin, and it reads the grain from a different place.

Built-in scenarios

The builders live under crates/nova_authoring/src/base_content/scenarios/. main_menu/ gives each menu backdrop its own file, and nova_protocol/ owns the campaign chapters plus shared cast and pacing vocabulary. Its shakedown/ module builds the New Game starter - the beat-chain reference: one beat counter gates every handler, and count milestones run on OnUpdate handlers keyed on the counter (handler order within one event is not load-bearing). The builders are an OFFLINE inventory, not the runtime path: content -- gen serializes them to the committed assets/base/scenarios/*.content.ron, base.bundle.ron lists them, and crates/nova_assets/src/merge.rs merges the parsed RON into GameScenarios like any mod’s. content_ron_parity pins builders == RON.

Adding new pieces

  • Event: event + info structs in nova_events/src/lib.rs, an EventConfig variant in events.rs, and something that fires it (engine-driven events live in loader/ - OnStart in lifecycle.rs, OnUpdate in clock.rs, the orbit/lock trackers in trackers.rs; area events in objects/area.rs; OnNeutralized fires from nova_gameplay’s integrity stack, and a rock’s OnDestroyed from objects/asteroid_carve.rs when its field is exhausted).
  • Action: config struct + EventAction<NovaEventWorld> impl in the right actions/ submodule (flow/mission/sequence/ship/spawn/timer/ view), plus an EventActionConfig variant in actions/mod.rs.
  • Filter: same pattern in filters.rs (EventFilterConfig).
  • Object: a module under objects/ (config + bundle function, plugin in objects/mod.rs) plus a ScenarioObjectKind variant/match in actions/spawn.rs. Its *_TYPE_NAME const goes in nova_events, beside EntityTypeName, so readers below nova_scenario can match on it.

Find it in the code

  • Engine plugin: NovaScenarioPlugin - crates/nova_scenario/src/lib.rs; generic dispatch: EventHandler - crates/nova_events/src/engine.rs.
  • Vocabulary enums: EventConfig - crates/nova_scenario/src/events.rs; EventFilterConfig - crates/nova_scenario/src/filters.rs; EventActionConfig - crates/nova_scenario/src/actions/mod.rs.
  • The state seam: NovaEventWorld - crates/nova_scenario/src/world.rs; variables and expressions - crates/nova_scenario/src/variables.rs; their text form: crates/nova_scenario/src/syntax.rs.
  • What an authored string names: Names - crates/nova_scenario/src/names.rs; the editor that reads it - crates/nova_editor/src/event.rs (the script as nodes).
  • Loading and scoping: ScenarioLoaderPlugin, ScenarioScopedMarker, scenario_is_live - crates/nova_scenario/src/loader/mod.rs; the glTF warm-up: ScenarioPreload, scenario_render_meshes - crates/nova_scenario/src/loader/preload.rs; what the pulse wakes for: WakeProfile, configure_scenario_shape - crates/nova_scenario/src/loader/wake.rs.
  • Objects: ScenarioObjectsPlugin - crates/nova_scenario/src/objects/mod.rs; kind dispatch: ScenarioObjectKind - crates/nova_scenario/src/actions/spawn.rs.
  • Asteroid carving: AsteroidField, carve_asteroid_fields, pristine_field - crates/nova_scenario/src/objects/asteroid_carve.rs; the mesher underneath: SignedField - crates/nova_gameplay/src/mesh/field.rs; the cost of the material itself: DAMAGE_PER_UNIT_VOLUME - crates/nova_gameplay/src/integrity/carve.rs.
  • API detail: cargo doc --open -p nova_scenario (event engine: -p nova_events).

The automation harness

How Nova Protocol drives itself without a human at the keyboard: a scripted autopilot walks the game through its states, screenshot drivers capture the result, and a completion protocol decides when the run is over. It is what makes headless verification, the web figures and the performance reports repeatable rather than a manual pass.

All of it lives in the nova_autopilot crate, which depends on bevy alone - no nova_* crate, no avian3d. It ships the drivers and the protocol; it does not ship anything Nova-specific. The adapters that know about Nova (scenario presets, camera posing, freezing rigid bodies, hiding the dev overlay) stay in nova_debug and reach in through caller-supplied closures. The drivers are generic over the app’s state type, and that generic is what keeps GameStates - and with it the whole game dependency tree - out of the crate.

Read this page as the crate’s contract. Nova’s own examples run these drivers, reaching them through the nova_debug prelude and the nova_debug::harness presets - the Nova-flavored adapter, not a second implementation - while nova_probe names nova_autopilot::completion directly.

What it drives

DriverDoesWhat Nova uses it for
AutopilotPluginWalks a list of named steps, each advancing when its predicate over the world holdsHeadless smoke runs, driving a scenario while something else measures
capture_windowWrites the primary window to a PNG and acks it into CaptureLog. Not a driver - the primitive a script’s shot step callsThe web figures and thumbnails, captured at 1920x1080
completionThe registration and exit protocol every driver reports toEnding a run once, when everyone is finished

nova_probe_cli (the game binary’s probe subcommand, debug feature only) is the host layer above: it arms the harness variables, spawns a subject as a child process and turns the output into a correctness and performance report. It arms the variables below - including a window-sized NOVA_AUTOPILOT_DEADLINE for its fps pass, which your own value overrides. Its in-game half, nova_probe, is what the subject wires to collect the evidence.

A harness run is headless, which for these drivers means a SOFTWARE X server, and that is not free: presenting a window under Xvfb costs a CPU copy of every pixel, charged to the frame. Correctness is unaffected and so is any ratio, but an absolute millisecond off a headless run is the game plus the display server. Measuring performance has the size of it and what survives.

The subject is usually an example, which wires the collectors itself. For probe scenario it is the GAME BINARY: src/main.rs adds nova_probe::NovaProbePlugin and the nova_autopilot() preset under the same debug feature that carries the probe subcommand. Both are inert without their variables, so a plain cargo run --features debug behaves exactly as before - and a scenario becomes measurable without an example file existing for it.

The environment contract

Every driver is inert unless its own variable is set, so an app adds the plugins unconditionally and a normal run pays nothing for them. Setting the variable is what arms the driver; the value only matters where the table says so.

VariableArmsRead byValue
NOVA_AUTOPILOTthe scripted state driverAutopilotPluginany (presence only)
NOVA_CAPTUREthe CAPTURE path of a script that has one: its shot steps write PNGs instead of driving straight throughcapturing(), which a script reads while building its stepsany (presence only)
NOVA_CAPTURE_DIRnothing on its owncapture_window, and the scenario Screenshot action (nova_scenario/src/actions/view.rs) reads it independentlydirectory that relative capture paths resolve under; absolute paths ignore it
NOVA_AUTOPILOT_DEADLINEnothing on its ownthe completion watcherseconds before the run gives up and error-exits naming the laggards (default 120); the RUN-level backstop under a script’s own per-step deadlines

NOVA_CAPTURE arms the SHOTS, never a driver. A capturing run therefore sets NOVA_AUTOPILOT too, and one script owns the window: there is no second driver to fight it over NextState.

That is the DRIVER contract in full - four variables. It is not every NOVA_* variable the workspace reads: Environment variables indexes the whole set and says which crate owns each, and the measurement knobs’ own values and defaults are tabulated once in nova_probe’s crate rustdoc - cargo doc --open -p nova_probe - because the same table serves the wasm build as URL query parameters. Measuring performance covers what they are FOR. A variable that arms nothing is silent, so a run pinned to a name that is on none of those pages does a plain play-through and reports nothing wrong.

Reading the world instead of looking at it

The timeline says what HAPPENED. The world-state snapshot (nova_probe::capabilities::snapshot) says what the world LOOKS like: one JSON object holding every ship - identity, pose, velocity, aggregate health, mass, the collapse/defeat flags and its weapon locks - each ship’s sections with their class, pose, health, modifications and magazine state, each section’s fixtures (the skin plates and decor bolted to it), and every torpedo and round in flight with its owner, damage, remaining lifetime and - for a torpedo - which ordnance TYPE it is, since two bays on one hull can load different torpedoes that are identical in every other field.

Use it when a defect would otherwise be judged from a render. A skin bug, a section that took damage it should not have, a turret that never reloaded: all of them are one jq query away instead of a picture to squint at.

Xvfb :95 -screen 0 1280x720x24 &
NOVA_AUTOPILOT=1 NOVA_PROBE_SNAPSHOT=/tmp/snap.jsonl \
  NOVA_PROBE_SNAPSHOT_FRAMES=600,600 BEVY_ASSET_ROOT="$PWD" DISPLAY=:95 \
  cargo run --example system_turret_gunnery --features debug
jq -S '.ships[0].sections[] | {id, class, health}' /tmp/snap.jsonl

Two rules make it a DIFFABLE artifact rather than a dump. Every list is sorted by a value-derived key, never by entity id or query order, so a respawn that renumbers entities does not churn the diff. Every float is rounded to four decimals with -0.0 normalized, so the last bit of an f32 does not either. Two snapshots of one frozen frame are byte-identical, which is what the repeated frame number above checks.

It is read-only, deliberately. There is no restore: a scenario is already replayable and is its own checkpoint.

The completion protocol

One run can carry several collectors - an autopilot timeline, a frame capture - each finishing on its own clock. A collector that writes AppExit on its own clock discards every other collector’s data, and does it silently: a capture cut short by another collector’s exit still writes a plausible file, just a shorter one. So the exit is NEGOTIATED. Two rules:

  1. Register before the run starts. A collector calls completion::register from its Plugin::build, behind its own armed check. Nothing joins later, and an unarmed collector must not join at all - it would hold the exit open until the deadline.
  2. The app exits only when every registrant reports done. A collector calls HarnessCompletion::done and never writes AppExit::Success itself; the watcher writes it once the pending set empties.

An error exit is the exception: a collector that genuinely fails (a screenshot that cannot save, a stalled script) writes AppExit::error directly, because an abort is not a completion and must not wait for anyone. The deadline backstop does the same, naming the collectors still pending, so a supervisor reads “capture never completed” in the log instead of watching a silent hang.

Writing a script

A script is a list of STEPS. A step is a name plus four optional parts and one required one:

PartMeaning
namewhat a log line and a stall message call this beat
enterthe state to set on entry
on_entera world action run once, on entry (a synthesized gesture, a scenario poke)
eacha world action run every frame, with the IN-STEP elapsed seconds
untilthe predicate that advances the step
deadlinein-step seconds after which an unsatisfied until ABORTS the run, naming the step

The step advances the first frame until holds. Name a step after what it is waiting FOR, not after what it pokes - the name is what a stall message carries, and “stalled on lock the prey” is a diagnosis where “stalled after 30 seconds” is a shrug.

Elapsed time is one predicate among many, so hold(state, secs) - enter a state, wait N seconds - is sugar for step("hold:<state>").enter(state).until(elapsed(secs)) rather than a second mechanism. The vocabulary is in nova_autopilot::predicate (elapsed, frames, state_is, resource_where, any_entity, ui_node_present, pointer_pressed, pointer_released, pointer_at, shot_written, loop_written, and, or, not), the gestures in nova_autopilot::input (press_key, release_key, type_text, press_edit_key, press_mouse, release_mouse, move_cursor, click_at, scroll_lines, scroll_pixels), and the Nova-typed predicates in nova_debug::harness (scenario_variable_is, section_gone, player_ship_present, and the editor_* set below). Anything the vocabulary cannot express is a plain closure: Arc::new(|world: &World| ...).

A key press is two channels, and a beat has to pick

Bevy delivers a key twice. press_key writes ButtonInput<KeyCode>, which is what a VERB reads. press_edit_key writes a KeyboardInput message, which is what a TEXT FIELD reads. Nothing bridges them, so a beat that drives one channel proves nothing about the other: pressing only KeyCode::Delete at a focused field asserts that the verb stayed quiet while the field was never asked to delete anything, and pressing only Key::Delete asserts the opposite half.

A beat about who owns the keyboard therefore drives BOTH and reads both outcomes - see examples/systems/system_input_modes.rs, which presses each key down both channels and then asserts what the field holds as well as what the document holds.

A UI gesture is three waits, not three sleeps

A click on a widget owes the app three acks, and each one is a predicate:

BeatWaits onWhy a frame count is not that
the widget is upui_node_present(name)click_named WARNS and continues on an unresolved name, so a press at a panel that has not laid out is a beat silently lost
presspointer_pressed()the picking backend turns the button event into pointer state a PreUpdate later, and a release before that is a click the widget never saw
releasepointer_released()Activate fires on the release edge, so this is the beat the button’s effect belongs after

Two things those predicates are careful about, because both were wrong once. ui_node_present waits for a BOX, not for an entity: a node that has not been through ui_layout_system carries ComputedNode::default() - zero size at the origin, the same value a Display::None node keeps - and advancing on that hands click_named a degenerate rect at the window corner, which is the race the settles existed for. And the pointer acks answer for PointerId::Mouse alone, because that is the pointer the gestures drive; an app may carry others (Nova’s terminal parks a forwarded one), and a second pointer sitting in the opposite state would otherwise ack for the one the beat actually moved.

What the button DID is the caller’s next beat, and it is a condition too. The editor publishes its own decisions as data for exactly this: nova_editor’s public, read-only EditorProbe carries the armed tool, what a click would build right now (solved, refused with the reason, or nothing), whether the parts gallery is up, whether its filter holds the caret, and which prototype the selection resolves to. nova_debug::harness wraps those as editor_tool_is, editor_part_armed, editor_placement_solved, editor_placement_refused, editor_placement_clear, editor_gallery_open, editor_gallery_closed, editor_filter_focused and editor_gallery_selected.

So a placing gesture reads: aim, hold until the editor SOLVED a placement there; press, hold until the pointer registered it; release; hold until the section landed. system_ship_editor and the screenshot_editor walk are written that way end to end, and neither counts a frame outside a pre-shot settle.

Where the only observable IS the outcome, the beat may share the assert’s quantity - the exception to the strictly-weaker rule below. “The socket filled” has no stimulus-side twin: the count is both what the beat waits for and what the verdict states. The trade is deliberate, and it is the better half: an unmet until aborts naming the beat that missed, where a snapshot assertion a fixed number of frames later reported a wrong number and left which gesture produced it to be guessed. Keep the assert anyway - it is where the claim is written down.

Typing is its own gesture because a key has two halves. press_key writes the HELD state (ButtonInput<KeyCode>), which is what flight code polls; type_text writes the keyboard MESSAGE carrying the text a keypress produced, which is what a text field reads. A run that drives a filter or a name field wants the second - pressing KeyR fills nothing in.

A driven run owns the pointer. The examples run on a real display, so a real cursor event - the window manager’s enter/motion pair, a developer nudging the mouse, the echo of the OS-level warp the driver itself performs - lands in the same stream the synthesized one does. One of those between a press beat and its release beat CANCELS the click silently: bevy_picking dispatches Pointer<Click> from the PREVIOUS frame’s hover map, so a pointer that moved off the widget in between produces a release, no click, and no Activate - a 90-second stall on the beat AFTER the one that actually went wrong. So the autopilot pins its pointer: whenever the window disagrees with the last position a gesture set, nova_autopilot::input puts the pinned position back in First, before the picking backend reads the frame’s events. Nothing at a call site changes, and a script needs no defensive re-hover. The pin holds the REAL cursor too, since that is what Window::cursor_position moves - so a driven run on your own desktop pulls the mouse back whenever you move it off, until the run ends.

A settle predicate over a physics quantity belongs on the physics schedule. “The value held still for N frames” is a common way to write “the solve is done”, and on Update it does not mean that: avian runs in FixedPostUpdate, so above the fixed rate most Update frames carry no solve pass at all and N unchanged frames can all precede the recompute the beat is waiting for. Sample in FixedPostUpdate after PhysicsSystems::Prepare, so one sample is one pass, and carry any second fact the beat needs (an entity count, say) in the same sample rather than reading it live off the world. system_hull_damage’s ComSettle is the worked example.

The rule is “sample where the quantity is WRITTEN”, not “sample on the fixed schedule” - system_turret_gunnery’s AimSettle stays on Update precisely because its aim error is produced by SmoothLookRotationPlugin in PostUpdate, so consecutive fixed ticks inside one frame would read the same value and saturate the streak mid-slew. What that costs is framerate independence, which you buy back separately: a per-frame delta threshold means a different physical threshold at every framerate, so compare a RATE against Time::delta_secs(). AimSettle is the worked example for that half.

Related, and the other half of the same mistake: a beat must be strictly weaker than the assert that follows it. If the beat’s predicate and the next step’s assert share a constant, the assert cannot fail and a real regression surfaces as a deadline stall on the beat’s name instead of the message that explains it. Gate on the stimulus and the world having stopped changing; assert on where it ended up. When the two must read the same quantity, give the beat a margin - the driver enters the next step on the FOLLOWING frame, so a beat that opened at exactly the assert’s threshold on a falling value hands the assert a failing one (system_attitude_hold’s OFFSET_BEAT_MARGIN_SECS, and system_turret_gunnery’s GATE_TRAVEL_BEAT_MARGIN over a sweep that is not even monotonic).

Where the invariant has no separate stimulus-side observable - “the torpedo detonated”, “the burn accelerated” - the beat is the stimulus plus a bounded settle sized off the mechanism (LAUNCH_SETTLE_SECS, BURN_WINDOW_SECS, HIT_SETTLE_SECS), never the outcome itself. A settle is a runway, so it owes a derivation on its constant; what it must not do is read the quantity the assert decides.

The runway anti-pattern

The shape to avoid is a wall-clock RUNWAY plus one closure that re-derives a step machine from booleans:

#![allow(unused)]
fn main() {
app.add_plugins(
    AutopilotPlugin::<GameStates>::new()
        .self_completing()
        .hold(GameStates::Loading, 30.0)   // a runway, unrelated to Loading
        .input(script),                    // every frame, in every state
);
app.add_systems(Last, guard_script_completion);

fn script(world: &mut World, elapsed: f32) {
    if *world.resource::<State<GameStates>>().get() != GameStates::Playing {
        return;
    }
    // Re-derive a step-relative clock by hand, because `elapsed` is the run's.
    let playing_since = { /* get_or_insert into a script resource */ };
    let t = elapsed - playing_since;
    if t > 1.0 && !script.spun { script.spun = true; apply_spin(world); }
    if t > 2.0 && !script.killed_controller { /* ... */ }
    // ... and a hand-rolled panic if the runway expires with beats unplayed.
}
}

Everything in it is a symptom: the playing_since offset exists because the closure’s clock is the RUN’s and not the beat’s, the booleans exist because a closure has no notion of having advanced, and the hand-rolled guard exists because nothing else knows the runway expired with beats unplayed. Each is carried by the step list for free.

Write the beats themselves, each waiting on the world (examples/systems/system_hull_damage.rs):

#![allow(unused)]
fn main() {
app.add_plugins(
    AutopilotPlugin::<GameStates>::new()
        .step("load the rig")
        .enter(GameStates::Loading)
        .until(player_ship_present())   // not "30 seconds should do it"
        .deadline(15.0)
        .add()
        .step("spin the ship")
        .on_enter(apply_spin)
        .add()
        .step("kill the controller section")
        .on_enter(kill_frontmost_section)
        .until(section_gone("controller"))  // the real despawn, not a guess
        .deadline(6.0)
        .add()
        // Last beat: the driver reports done after it, so the run ends on the
        // assertion instead of idling out the rest of a runway.
        .step("assert the com follows the surviving sections")
        .on_enter(assert_com_follows_sections)
        .add(),
);
}

There is no runway, and a stalled step is the driver’s job to report, by name.

Capturing: one idiom

There is one way to take a screenshot from a script, and it is a step. A shot step calls shoot(world, "name.png") from on_enter, so the act, the framing and the shot it produces read top-to-bottom in the step list:

#![allow(unused)]
fn main() {
.step("frame the planetoid")
.on_enter(|world| pose_camera(world, EYE, LOOK))
.until(frames(SETTLE_FRAMES))
.add()
.step("shoot wiki-gravity.png")
.on_enter(|world| shoot(world, "wiki-gravity.png"))
.until(shot_written("wiki-gravity.png"))
.deadline(SHOT_DEADLINE_SECS)
.add()
}

shoot is its own gate: it captures only when NOVA_CAPTURE is set, so the SAME script is both the capture run and the smoke run.

shoot is asynchronous (the PNG lands at the end of a later frame), which is why the shot step holds instead of ending immediately: move the camera in the same frame and the pending capture renders the NEXT framing. It holds on the ACK, not on a guessed number of frames - capture_window records the path in CaptureLog once the write completes, and shot_written reads that. On the smoke path, which shoots nothing, the same predicate holds immediately, so a script never branches its step timing on capturing().

That leaves ONE settle in a capture script, the same on both paths: SETTLE_FRAMES, the frames a beat needs to come to rest before its shot. The per-example splits that preceded it were each carrying the write latency on top of the stillness, which is why they disagreed.

The scene dressing is separate from the steps and lives in nova_debug::harness too: force_capture_resolution (a known framing for every shot in the fleet), hide_dev_overlays / hide_hud, and freeze_bodies for a posed set that must not drift between framings.

A moving figure is the same idiom on a longer beat: loop_start / loop_end around the action, loop_written to hold the closing step, and LoopCapturePlugin next to the script. Every size and rate that recorder uses is ONE value, LoopProfile - the window it renders into, the resolution it encodes to, the frame clock, the CRF and the frame cap:

#![allow(unused)]
fn main() {
app.add_plugins(LoopCapturePlugin::default());          // 1920x1080 -> 720p30
app.add_plugins(LoopCapturePlugin::new(LoopProfile {    // a portrait master
    window_resolution: (1080, 1920),
    output_resolution: (1080, 1920),
    fps: 60,
    crf: 18,
    frame_cap: 1200,
}));
}

LoopCapturePlugin::default() is what the docs fleet captures with; a production caller passes its own profile and changes nothing else. force_capture_resolution reads the profile the plugin publishes (armed or not), so the window, the frame clock and the encode cannot disagree about the framing - and a profile whose output equals its window encodes with no scale filter at all, rather than resampling a master through itself.

Do not add a second capture idiom beside it. A driver that walks its own list of shots builds that list away from the script producing the state each shot frames, which puts timing and framing in different files - and they drift. A range that only wants ONE settled picture of itself does not get a driver either: nova_screenshot(script) appends the settle-and-shoot beat to the script it already has.

Deadlines

A step’s deadline is IN-STEP seconds and is unset by default, leaving NOVA_AUTOPILOT_DEADLINE as the run-level backstop for a script that hangs somewhere without one. Set a deadline where a stall is worth NAMING, and keep the sum of a script’s deadlines under the run-level value - otherwise the generic hang detector wins the race and the named-step diagnostic is lost. That ordering is documented, not enforced: the run-level value comes from the harness that launches the process, which the crate cannot see.

Do not enter a state something else owns

enter force-sets NextState, which is why a Nova script does not enter GameStates::Playing: the Loading -> Playing transition is asset-gated by the loader, so forcing Playing either fires before the GameAssets resource exists (panicking the scene setup that reads it) or re-enters a state the loader already entered, double-running OnEnter(Playing). Enter the state BEFORE the gate and let the loader do its own transition, then wait on something the load produced - player_ship_present(), a seeded scenario variable. Nova’s nova_autopilot() preset (crates/nova_debug/src/harness.rs) is the wall-clock fallback for examples with nothing observable to wait on.

Then arm it from the shell - driven_app is the crate’s own example, the scenario forms are Nova’s:

NOVA_AUTOPILOT=1 cargo run -p nova_autopilot --example driven_app
NOVA_AUTOPILOT=1 NOVA_CAPTURE=1 NOVA_CAPTURE_DIR=target/shots \
  cargo run --example system_scenario_grammar --features debug
NOVA_AUTOPILOT=1 NOVA_CAPTURE=1 NOVA_CAPTURE_DIR=target/shots \
  cargo run --example screenshot_gravity --features debug

crates/nova_autopilot/examples/driven_app.rs is the end-to-end read: a self-contained DefaultPlugins app with its own state machine, driven through named predicate steps and exited by the completion protocol, importing no nova_* crate but nova_autopilot. Run it with NOVA_AUTOPILOT=1 cargo run -p nova_autopilot --example driven_app; crates/nova_autopilot/tests/autopilot_example.rs runs the same thing headless and asserts on the exit status and the log lines.

The full API reference is the crate’s rustdoc (cargo doc -p nova_autopilot --open); every public item is reachable through nova_autopilot::prelude.

Find it in the code

  • Driver: AutopilotPlugin - crates/nova_autopilot/src/autopilot.rs; capture_window - crates/nova_autopilot/src/capture.rs.
  • Protocol and vocabulary: completion - crates/nova_autopilot/src/completion.rs; predicates - crates/nova_autopilot/src/predicate.rs; gestures - crates/nova_autopilot/src/input.rs.
  • Nova adapter: the nova_autopilot() and nova_screenshot() presets, shoot, scene dressing - crates/nova_debug/src/harness.rs.
  • Host layer: the probe subcommand - crates/nova_probe_cli/src/native.rs; in-game half: NovaProbePlugin - crates/nova_probe/src/capabilities/mod.rs. What the capture measures and how to read it: Measuring performance.
  • End-to-end example: crates/nova_autopilot/examples/driven_app.rs.
  • API detail: cargo doc --open -p nova_autopilot.

Environment variables

Every NOVA_* variable the game reads, in one place: what it gates, which crate owns it, and who it is for. This page is the INDEX. It does not restate what each variable means - the page that owns the mechanism does that, and the link in each row goes there.

Nothing type-checks an environment variable. Renaming one compiles clean and fails at load, or - worse - silently stops arming something and the run looks fine while measuring nothing. So there is one rule, and tests/env_contract.rs enforces it:

  • Every variable is a named constant, declared once, in the crate that owns the behaviour it gates. No bare literals at a use site in crates/ or src/. The measurement knobs are computed from one prefix by nova_probe::probe_env, so the host that pushes a name into a child run and the child that reads it back cannot disagree.
  • The test is also a roster. It walks crates/, src/ and tests/ for NOVA_* literals and fails on anything it does not name, so a new variable cannot arrive undeclared.
  • examples/ keeps its literals on purpose. A range that drifts turns a probe run red, which is detection shipped code does not have (AGENTS.md Nova behavior).

Three names are spelled twice, deliberately: nova_gameplay’s mute policy, nova_scenario’s Screenshot action and the probe sandbox each need a name another crate owns, and a dependency edge from a shipping crate to a dev-tooling one - or from the host harness to the whole asset stack - is worse than the repetition. The contract test asserts each pair equal.

Who the columns are for

  • harness - set by a run script, by CI, or by probe. A player never sees it, and an unarmed run pays nothing for it.
  • tooling - set by a test or a contributor’s shell to keep a run off the real profile on disk.
  • player - usable on a normal cargo run or a shipped build.

The harness: what drives a run, and where its pictures go

Owned by nova_autopilot. The mechanism is The automation harness, which carries the values and the stall semantics.

VariableGatesFor
NOVA_AUTOPILOTarms the scripted state driver; unset, the plugin adds nothingharness
NOVA_AUTOPILOT_DEADLINEseconds before the completion watcher error-exits naming the laggardsharness
NOVA_CAPTUREputs a script on its CAPTURE path - its shot beats write PNGs, its loops recordharness
NOVA_CAPTURE_DIRdirectory relative capture paths stage under; absolute paths ignore itharness

NOVA_CAPTURE arms the SHOTS, never a driver, so a capturing run sets NOVA_AUTOPILOT too and one script owns the window. NOVA_CAPTURE_DIR is also read by the scenario Screenshot action, which is the in-game photo-mode lever rather than a harness one.

Measurement: what a run records about itself

Owned by nova_probe, all under one prefix, all inert unless set. The full table - defaults, units, and the wasm URL-query twin of each - is the crate’s own rustdoc (cargo doc --open -p nova_probe), and Measuring performance is what to read before quoting a number from any of them.

VariableGatesFor
NOVA_PROBEarms frame-time capture, the scene census and the frame-cost breakdownharness
NOVA_PROBE_MODEcorrectness drops the measuring passes from a child runharness
NOVA_PROBE_WARMUP / _FRAMESthe capture window, in frames; wins over an example’s declared oneharness
NOVA_PROBE_OUTdirectory the run writes frametime.csv, <label>.json and census.json intoharness
NOVA_PROBE_LABELthe row label a capture records itself underharness
NOVA_PROBE_RESforced primary-window resolution for the measured runharness
NOVA_PROBE_RENDER_SCALEforces the render-scale lever, holding the rest of the quality preset fixedharness
NOVA_PROBE_MAX_DELTAceiling on how many fixed steps one frame may runharness
NOVA_PROBE_PRESENTpresentation mode forced on the primary windowharness
NOVA_PROBE_QUALITYgraphics preset for the run, recorded in the metadataharness
NOVA_PROBE_SCENARIOthe scenario a sweep cell measuresharness
NOVA_PROBE_SHA / _HOSToverride the recorded git SHA and host tagharness
NOVA_PROBE_CENSUS_FRAMEframes after Playing at which the scene census is takenharness
NOVA_PROBE_FRAMECOST_FRAMESframes averaged into one frame-cost reportharness
NOVA_PROBE_RENDER_DIAGasks the renderer for GPU timestamp queries, so passes can be namedharness
NOVA_PROBE_TIMELINEJSONL path for the run timeline: states, events, variables, markersharness
NOVA_PROBE_INVARIANTSarms the continuous engine-bound invariant checksharness
NOVA_PROBE_CONTRACTJSON path the run declares its wired capabilities toharness
NOVA_PROBE_SNAPSHOT / _SNAPSHOT_FRAMESJSONL path for world-state snapshots, and the frames to take them atharness
NOVA_PROBE_STEPDIAGCSV path for the per-fixed-step physics diagnosticsharness
NOVA_PROBE_STEPDIAG_BODIESthe body-count REGIME floor its end-of-run summary is taken overharness
NOVA_PROBE_SANDBOX_RESOLVER_CHILDmarks the re-executed child in the probe host’s own sandbox testharness

NOVA_PROBE_RENDER_DIAG is declared in nova_core rather than nova_probe: the wgpu feature can only be requested where RenderPlugin is built, and nova_core is the lowest crate both name.

Outputs off

One variable per output device, and a matching debug-only flag on the game binary. An example has no command line of its own, which is why the environment half exists at all. See Building and running.

VariableFlagGatesOwnerFor
NOVA_NORENDER--norenderevery AppBuilder::new() in the process assembles a headless app: no device, no window, no winitnova_coreharness
NOVA_MUTE--mutezeroes the audio OUTPUT; the volume setting is untouchednova_gameplayplayer

NOVA_MUTE unset still mutes a run that has NOVA_AUTOPILOT or NOVA_CAPTURE set; NOVA_MUTE=0 forces sound through one. The flag wins over both, and a muted run says nova audio: output muted for this run once.

The replay seed

Owned by nova_gameplay, next to the entropy plugin it seeds.

VariableGatesFor
NOVA_SEEDseeds the gameplay RNG with one u64, so a driven run replays byte for byte; unset, the OS seeds it and no two runs agreeharness

A value that does not parse as a u64 refuses the boot rather than running unseeded - a replay that silently lost its seed is the failure the knob exists to prevent.

Modding, and the settings store

Owned by nova_assets. See /create/publish-a-mod/ for the portal.

VariableGatesFor
NOVA_MODDING_CACHE_ROOTmoves the local mod cache off the platform data dirtooling
NOVA_MODDING_PORTAL_URLpoints a native build at another portal treetooling
NOVA_CONFIG_ROOTmoves the settings store off the platform config dirtooling

NOVA_CONFIG_ROOT is deliberately NOT in the modding family. It is the settings store root, and its name is already right.

The menu

VariableGatesOwnerFor
NOVA_MENU_BACKDROPpins the menu backdrop to one menu_backdrop scenario id instead of re-rolling the draw; an unknown id warns and falls backnova_menuharness

Not on the roster

  • Example-local knobs. NOVA_STRESS_PD_*, NOVA_EDITOR_FRAMELOG, NOVA_SOAK_SCENARIO, NOVA_SOAK_SECS belong to one example each and stay literals beside the range that reads them.
  • NOVA_OS_*. Around 180 of these exist and NONE is an environment variable: they are const Color, layout and volume values in nova_os_ui, nova_os and nova_gameplay::audio. A grep for NOVA_[A-Z_]* is dominated by them, so count env::var call sites instead of identifiers.
  • Shell-only. NOVA_UI_PORT, NOVA_GAME_PORT, NOVA_MODS_PORT, NOVA_MODS_DIR, NOVA_PORT_LO/_HI are read by scripts/ and web/webpack.config.js; NOVA_BENCH_* by benchmark/. No Rust reads any of them.
  • Foreign variables the code legitimately reads: DISPLAY, WAYLAND_DISPLAY, RUST_LOG, BEVY_ASSET_ROOT, CARGO_*, CI, XDG_DATA_HOME, XDG_CONFIG_HOME, LVP_ICD, RUSTFLAGS, VK_ICD_FILENAMES, VK_DRIVER_FILES, WGPU_BACKEND, TRACE_CHROME.

Adding one

  1. Declare it as a named constant in the crate that owns the behaviour it gates. A measurement knob belongs in nova_probe, never in a gameplay plugin - the four fixed-step knobs that lived in NovaGameplayPlugin, one of them able to panic! on a malformed value, are the worked example of getting this wrong.
  2. Export it through that module’s prelude.
  3. Add it to the roster in tests/env_contract.rs. The test fails until you do, which is the point.
  4. Add a row above.

Measuring performance

How a frame’s cost is measured here, and the ways a measurement lies. The harness that produces the numbers is the same probe front door Building and running uses for correctness; this page is the INSTRUMENT half - what the capture does, what a number is worth, and what has to be true before one can be quoted.

Read Run verification (probe) first for the run mechanics (the verbs, the run directory, the report). Everything below assumes a run has happened.

The capture

An env-gated capture plugin drives the real gameplay app to Playing, warms up, records the wall-clock delta of every frame for a fixed window, and writes percentile stats. It is inert unless NOVA_PROBE is set, so the whole fleet carries it permanently. Probe runs it as a DEDICATED capture-only pass when the program declares it (the correctness recorder flushes per entry on the frame path - measurement and correctness never share a pass), the harness completion protocol keeps the app alive until the window closes, and enrolled scenes (a script loop_from point) reload + replay so the window measures activity - reload intervals are excluded from the stats and reported as their own line.

Which runs get that pass is the PROGRAM’s own answer, read back off its contract. NovaProbePlugin::default() wires the capture, so a cataloged example makes a frame-cost claim unless it says otherwise with without_frametime() - which is what every screenshots/ producer and the playable/ galleries do, since a posed still has no frame cost worth defending. A program that wired no capture is inert, and its contract tells the report the frame-time section is empty because the program makes no frame-cost claim - not because a capture went missing.

What the report does with the numbers is REPORT them. The Performance section leads with the worst frame, the mean, and what each comes to in FPS, flagged when it is under 60; checks.json mirrors it under frames, carrying graded: false. Nothing passes or fails on a frame-time number - whether a scene is fast enough on this machine, in this build profile, is the reviewer’s call and always was.

One capture cannot prove a tail moved

The worst frame is the number that matters - a stutter is a tail, and a mean hides it - and it is also the least repeatable thing the capture produces. Two captures of an UNCHANGED scene move it by tens of percent, while the mean and the median of the same two windows barely move at all. So a claim about the worst frame is made over a repeat SET, not over a run:

cargo run --features debug probe run wfc_arena --repeat 5

Each repeat is its own process, and each writes its own frametime.csv row labelled <subject>#<n>. The report then reads them as a set:

  • the reference is the MEDIAN of the repeats’ means (and of their medians), so one bad capture cannot drag the band over itself;
  • a repeat whose mean or median sits outside the band is DISCARDED - it met a different machine, or a different amount of scene;
  • the tail is read only across what survives, as the median of the admitted p99 values, printed with the spread of that group. The slowest single frame gets the same treatment and is printed beside it - as a reading, not as the number a claim is made on. It is one sample out of nine hundred and behaves like one; p99 is still a tail (the ninth-worst frame) and resolves roughly twice as small a change.

The spread is the point. It is the honest width of the number, and a claimed improvement smaller than it has not been measured. Discarding is not grading: checks.json carries the whole set under repeats with graded: false, and a discarded repeat says something about the machine, never about the code.

Two things the band cannot do for you. It is derived from a REFERENCE HOST and is a property of that machine, so re-derive it elsewhere. And it catches an outlier, not a DRIFT: a set taken immediately after a build slides monotonically down as the box recovers, the reference lands in the middle of the slide, and the gate throws out both ends. Let the machine settle before a repeat set, and treat a set the gate empties as “measure it again”, never as a result.

A refresh cap is a set’s finding, not a window’s

A run that named a presentation mode promising NOT to block on refresh can still be paced by the display: the surface falls back, or a compositor holds the swap chain, and every frame then waits for the same clock edge. What the capture reports is the display’s period, at a perfectly plausible-looking number.

Each capture measures its own CLUSTER SHAPE - the frame time the window collapsed onto, and the share of frames within 5% of it - and writes both beside its stats (cluster_ms / cluster_share in frametime.csv, on the summary line, and in the per-run JSON). A window that clusters at 0.72 or more on a period of at least 4 ms logs a SUSPECT reason=refresh_capped line and keeps its stats. The 4 ms floor is a display fact - nothing refreshes above 250 Hz - so a scene faster than that is cheap and steady, not capped.

The 0.72 bar sits above every workload this project has measured (point-defence stress 0.60-0.68, the damage-cracks A/B 0.64-0.65, 34 gallery captures 0.03-0.44) and below every real cap (a 165 Hz Fifo capture, 0.76-0.81). It started at 0.60 when only the outer two groups existed; the middle two landed in the gap afterwards. Expect to move it again, because it measures STEADINESS and the engine keeps getting steadier - that is precisely why clearing it is a suspicion and not a verdict.

Under a mode that MAY block on refresh (fifo, autovsync) the columns stay empty: clustering there is the mode working, so it is not evidence of anything and the honest record is that nothing was measured.

A suspicion, because one window cannot settle it. The check fires on STEADINESS, and an optimisation makes frames steadier - so a threshold applied per window preferentially accuses the FASTER arm of an A/B, which is a bias pointed at the null and the worst direction for an instrument to be wrong in.

The repeat set is where the evidence exists, because a refresh period is a CONSTANT. When two or more captures of a set are suspects and their cluster medians agree to within 1%, the set measured the display: every capture is discarded and the set reports no tail, the same shape as any other set the gate empties. When the medians DISAGREE, no display did that, and the captures are gated on their statistics like any others. checks.json carries the call under repeats.sets[].refresh_cap (refresh_capped, workload, unverifiable, not_suspected or unmeasured) with the per-capture shapes under it.

Agreement is a far tighter test than cluster membership, and the two numbers are not interchangeable. The 5% band spans the SCATTER inside one window, which is wide - a capped window is not a flat line. The 1% agreement spans the drift of a period ACROSS windows, which for a crystal-derived clock is none: the 165 Hz captures this was built from agree to under a tenth of a percent.

Neither bar is sound on its own, and the point-defence stress set is why. Four of its windows held 20.824 / 20.972 / 20.975 / 21.006 ms - agreement to 0.72%, inside the 1% that reads as one display. A steady workload really can reproduce its period, so agreement is necessary and nowhere near sufficient; the share bar is what separated that set. Expect any set that is both very steady and very reproducible to need both bars to survive.

One lone suspect in a set reads unverifiable: there is no sibling to check its period against, so whether it measured the display is UNMEASURED, and the set is neither refused nor waved through. Repeat it to settle it.

Was the window one scene?

A capture also records how many FIXED STEPS ran inside each frame, bucketed by count in the per-run JSON (fixed_steps), on the summary line, and as a table in the report’s Performance section (checks.json mirrors it beside frames). Bevy runs RunFixedMainLoop until the accumulated virtual time is spent, capped by Time<Virtual>::max_delta, so a frame that overruns the timestep hands its overrun to the next frame as extra steps.

Two readings matter, and no percentile shows either:

  • Frames that ran NO step. In a scene slower than the timestep that means the simulation was STOPPED inside the window - a pause, a menu, a result screen. A window carrying them did not measure one scene, whatever its mean says. In a scene faster than the timestep it is ordinary and says nothing.
  • Frames at the top of the range. When that count is max_delta / timestep the clamp is firing: those frames are discarding real time the world never simulates.

A stopped simulation is not merely reported, it is REFUSED. The capture reads Time<Virtual> directly, and a frame that arrives paused (or at relative speed zero) inside the warm-up or the window aborts the whole capture: it logs at ERROR naming the phase and the frame, writes NO frametime.csv row and no per-run JSON, and the capture_simulated check fails the run and lists every refused capture. A refusal rather than a flag, because a stopped scene keeps drawing at a steady cost - the mean and median it produces are exactly the shape a validity gate admits.

So a scene that can REACH AN END needs a window that closes before it does. An example declares its own with NovaProbePlugin::frametime_window(warmup, frames), sized from a measured run of that scene rather than guessed; wfc_arena does, because its 4v4 is a match that can be won.

A bounded window is still not enough on its own, because a scene can end before its clock stops. A fight whose losing side is gone is over while Time<Virtual> still ticks and every environment gate still passes; what the window measures then is the aftermath, at a fraction of the cost, in a row that looks like any other. wfc_arena meets this by construction - its capture opens on both teams having fired and connected, and a WIPE is what credits the last of that damage, so the gate can open onto an empty arena.

NovaProbePlugin::live_frametime(<predicate>) is the second half. The predicate is re-evaluated every warm-up and capture frame, and the first frame it fails refuses the window with reason scene_ended - same ERROR line, same discarded stats, same failing check as a stopped simulation. wfc_arena names “both teams still have a ship flying”. It says nothing about how MANY are left on purpose: a four-on-one is a fight in progress, and refusing that would be a judgement about workload rather than about the scene existing.

NOVA_PROBE_MAX_DELTA=<secs> forces the ceiling for a run, which is how a claim about the fixed loop gets tested instead of argued. Capping it in a SHIPPING build would trade a bounded tail for simulation time the world never runs, so it stays a measurement knob.

Pin it to one step before comparing two arms. The loop AMPLIFIES whatever it is handed. Write B for the per-frame base cost and s for the cost of one fixed step; a frame of measured cost F runs F / T steps at Bevy’s default Time<Fixed> period T, so

F = B + s * F / T      ->      F = B / (1 - s / T)

A frame is not slow because the fixed loop ran; the loop ran because the frame was slow, and then charged it again. As s approaches T the denominator goes to zero and the reading runs away, so two arms that differ by a little in B can differ by a lot in F - and the difference is the amplifier, not the change. Pinned to a single step per frame a capture reads B directly, and arms become comparable.

A capture under Xvfb measures the X server too

Read this before quoting any absolute millisecond a probe run produced. A software X server has no scanout, so presenting a window is a CPU-side copy of every pixel of it, and the render thread pays for that inside render_system after the graph has already finished. On this project’s host, at 1280x720, an EMPTY scene costs 16.7 ms under xvfb-run and 3.0 ms against a real display - same binary, same window, same pin, same Immediate presentation. The gap is linear in window pixels (1.4 ms at 160x90, 11.5 ms at 720p, 50 ms at 1440p) and does not move when NOVA_PROBE_RENDER_SCALE cuts the shading to a sixteenth, so it is the window and not the drawing.

It is an ADDITIVE constant, not a scale factor. An A/B whose two arms share a window size divides it out, so ratios and ablations under Xvfb stand. A budget, an FPS gate and a “this scene costs N ms” claim do not.

Two knobs make it visible. NOVA_PROBE_PRESENT=immediate names the presentation mode instead of requesting AutoNoVsync - bevy logs the fallback for a named mode and says nothing for the auto ones, so this is how a run proves it was not capped at refresh. NOVA_PROBE_RENDER_DIAG=1 asks the renderer for GPU timestamp queries and turns on the frame-cost report’s per-pass GPU table (it costs a resolve pass and a readback, about 3% of the frame, so it is never a default).

So an absolute number wants a REAL display, and the window does not have to be in your way to get one. An armed run wears the WM_CLASS / app id nova_core::MEASURE_WINDOW_CLASS and no other run does, so a window manager can send it elsewhere on its own - on i3, for_window [class="nova-measure"] move container to workspace 3. The class is deliberately distinct from the normal one, so a placement rule can never catch a hand-run somebody is playing.

Where a frame’s milliseconds went

Any armed capture also logs a nova framecost: line and, under it, three tables: every main-world schedule, every top-level RenderSystems phase with the render graph carved out of Render so the submit and the present are visible on their own, and every render pass the device timed. Read them together - the main world and the render world overlap under pipelined rendering, so a frame costs about the longer of the two, and GPU far under both says the device is not the constraint.

Beside it, nova census: counts the world once per capture: entities by component, the archetypes they fall into, and mesh instances against DISTINCT mesh handles. Instances and distinct always side by side - 12,572 instances over 681 meshes is a different story from the 12,572 alone. Distinct handles are what a draw call bins on, so the pair is what separates “the scene is big” from “the scene batches badly” (see Why cracks are QUANTISED).

The fixed loop is single-threaded on purpose

AppBuilder::assemble puts FixedFirst through FixedLast on Bevy’s single-threaded executor, so a schedule table’s fixed-loop rows are self time, not fan-out. Those schedules run 64 times a second and are made of many small systems; the multithreaded executor’s per-schedule task fan-out costs more than the parallelism buys. Matched at 650-750 dynamic bodies in a 1v1 wfc_arena fight, the per-step median measured 7.9 ms multithreaded against 6.1 single-threaded, with the capture’s 1% low 27 fps against 48; stress_point_defense at ~2,040 bodies measured 3.17 ms against 2.84.

Avian’s PhysicsSchedule and SubstepSchedule are LEFT multithreaded. The same switch applied to them moved no step metric and made the frame tail worse (p99 36.9 ms against 40.6): the solver’s par_for_each passes are the one part of a fixed step that does saturate threads. Re-measure before moving either boundary.

The window, and the deadline sized to it

The capture window is the capture crate’s full 180/900 baseline unless the example declared one of its own, so probe numbers stay comparable with the sweep’s; your NOVA_PROBE_WARMUP / NOVA_PROBE_FRAMES override both. The completion deadline is SIZED to the BASELINE window (not a flat 120s, and a ceiling for any shorter one an example declares): probe sets NOVA_AUTOPILOT_DEADLINE for the fps pass to (warmup + frames) / ~2fps + margin, so a slow-but-progressing capture (a heavy scene in a dev build under software rendering - the stress_* ranges are the case) completes instead of tripping the hang detector; a genuine hang still fails at a window-appropriate bound, and your own NOVA_AUTOPILOT_DEADLINE overrides it. Every example’s main returns AppExit, so a deadline expiry is a non-zero process exit the process_exit check reports. See the crate docs for the full knob list (NOVA_PROBE_*).

Sweeping presets, renderers and the web

The perf sweep is the same front door: a preset matrix of the frame-time capture, one labeled frametime.csv row per cell, release-built (dev-profile frame numbers are not baselines):

cargo run --features debug probe run stress_bullets --release --preset high --preset low
cargo run --features debug probe run stress_bullets --release --render sw ...  # lavapipe floor
cargo run --features debug probe run stress_bullets --release --norender      # no renderer at all
cargo run --features debug probe run <scenario> --platform web   # web/WebGPU capture (scraped)

--render picks the BACKEND for every pass, the frame-time one included: sw forces the lavapipe ICD and its short 20/120 window. --norender decides whether anything draws at all, sets NOVA_NORENDER in the child, starts no Xvfb, and is refused alongside --render - there is no backend to pick when nothing draws. It keeps the 180/900 baseline window, because a headless run has no fill cost to shorten around, and it is native only: a wasm run has no process environment to set. Its rows name themselves in frametime.csv - backend and adapter both read unknown, there being no adapter. It measures the main schedule alone, so it CANNOT see a render-side panic; a speed option beside a rendered run, never instead of one.

To measure a named SHIPPED scenario, use the probe scenario verb rather than run --scenario: it launches the game binary itself and needs no example. run --scenario only sets NOVA_PROBE_SCENARIO, which no cataloged example reads on the native side; on --platform web it is load-bearing, because nova_perf_web takes the scenario id from the URL.

Every capture records run metadata (wgpu backend + GPU adapter, resolution, graphics preset, git SHA, host and - schema v3 - the BUILD PROFILE) so a results file names its own renderer (pre-v3 files, like the v0.7.0 baseline, still load; their profile reads unknown). The report badges each row dev or release: dev numbers are NOT baselines, and because the capture is wired by default, the badge is what keeps ad-hoc dev captures from being mistaken for comparable measurements. The web platform builds the perf_web wasm app through Trunk, serves it from an embedded static server, drives headless Chromium with the calibrated WebGPU flags, and scrapes the summary line into a labeled CSV row (no fs in the browser). Compare runs with probe report <after> --baseline <before> - signed deltas per label - and report only accepts dirs probe itself produced (probe-run.json is the gate).

Profiled pass (where does the time go)

Per-system costs come from a SEPARATE traced run - tracing overhead inflates frame times, so a profiled run RANKS systems while the clean capture owns the FPS truth (never mix the two):

cargo run --features debug probe run system_scenario_grammar          # trace + report table
cargo run --features debug probe run system_scenario_grammar --samply # + flamegraph

The profiled pass builds with --features debug,trace (bevy’s per-system spans are compiled in only under bevy/trace), runs headless with TRACE_CHROME into the run dir (plus the RUST_LOG=bevy_ecs=info override that un-hides the spans from the game’s log filter), and the report renders the top-N table (probe report <run-dir> re-renders it). Open the raw trace.json in https://ui.perfetto.dev for the full picture; samply load opens the flamegraph in the Firefox Profiler (the samply run is skipped with a note when samply is missing or blocked - sampling needs perf_event_paranoid <= 1 AND, on many-core hosts, enough perf ring-buffer memory: an “mmap failed” means raising perf_event_mlock_kb, e.g. echo 16384 | sudo tee /proc/sys/kernel/perf_event_mlock_kb). The samply run builds with the dedicated profiling cargo profile (full DWARF in the binary + frame pointers via RUSTFLAGS) so our frames symbolicate to real names instead of raw addresses; frames inside the NVIDIA driver blob and stripped system libraries stay hex - that is their stripping, not a build problem. Load the profile right after recording: symbolication resolves from the binary on disk, so a rebuild in between loses names.

Expect the trace to be enormous. It carries one span per system per frame and grows at roughly 28 MB per second of traced gameplay, with nothing capping it, so a long range leaves GIGABYTES in its run dir - the report prints the size beside the table. That is deliberate: the raw file is the Perfetto artifact, and a byte cap or a span filter would buy disk by truncating the deep dive it exists for. The host reads it as a STREAM instead, at flat memory (about 70 MB peak, whatever the file’s size), so the cost is disk and disk only. It is a scratch artifact: keep it while you are profiling, delete the run dir when you are not.

Find it in the code

  • The capture, its window and its knobs: FrameTimePlugin, nova_frametime - crates/nova_probe/src/capabilities/frametime.rs. The crate rustdoc (cargo doc --open -p nova_probe) carries the full NOVA_PROBE_* table, native env var against wasm query string.
  • Where the milliseconds went: FrameCostPlugin - crates/nova_probe/src/capabilities/framecost.rs; the GPU half is gated on nova_core::RENDER_DIAG_ENV.
  • What an armed run looks like from outside: nova_core::PROBE_ENV and nova_core::MEASURE_WINDOW_CLASS - crates/nova_core/src/lib.rs. nova_probe re-exports PROBE_ENV; it lives down there because the window builder needs it.
  • What the scene contained: CensusPlugin - crates/nova_probe/src/capabilities/census.rs.
  • Driving a repeat set, the presets, the web platform, the traced and samply passes: crates/nova_probe_cli/src/native/. Reading one back - the validity band and the refresh-cap discriminator - read_repeats and RefreshCap in crates/nova_probe_cli/src/evaluation/frames.rs; the stats and CSV/JSON schema both halves speak - crates/nova_probe/src/stats.rs.
  • The bundle an example wires: NovaProbePlugin - crates/nova_probe/src/capabilities/mod.rs.

Add a ship section

Ship-section kinds are a CLOSED enum. There is no data-driven registry for them: SectionKind (crates/nova_ship/src/sections/base_section.rs) is a Rust enum, every match on it is exhaustive, and the compiler will not let you land a new variant until every site handles it. Adding a kind is a fixed sequence of ~10 edits across nova_ship, nova_gameplay, nova_scenario, nova_editor, nova_os_ui, and nova_authoring, ending at a runnable example.

Why it is closed

Section kinds are first-class engine concepts, not content. Each kind carries its own typed config, its own spawn bundle, its own behavior systems, and its own arm in every exhaustive match over section kinds – none of which a RON author could supply. What IS data-extensible is the section CATALOG: a SectionConfig (base stats + one SectionKind instance) is authored in RON and loaded at runtime (see the Ship sections reference and modding). New KINDS are code; new INSTANCES of an existing kind are data. This guide is about the former.

The closed enum is a feature: it means “did I wire the new kind into the ship computer / the editor / spawning?” is a compile error, not a silent gap.

flowchart LR
    A[config module] --> B[SectionKind enum]
    B --> C[SectionClass label]
    C --> D[section plugin]
    D --> E[spawn arm]
    E --> F[editor place + gallery]
    F --> G[asset prototype]
    G --> H[example]

Checklist

Do these in order. Steps 2-7 will not compile until the ones before them exist, and the exhaustive matches force you through 3-7 once the enum has the variant.

Replace <kind> / <Kind> below with your section name (e.g. shield / Shield).

  1. New config module. Create crates/nova_ship/src/sections/<kind>_section.rs, modelled on hull_section.rs (simplest) or turret_section/ (a multi-file module: behavior + FixedUpdate systems). It defines: a <Kind>SectionConfig struct, a <kind>_section bundle fn, a <Kind>SectionMarker component, a <Kind>SectionPlugin, and a prelude re-exporting them. The bundle MUST insert the marker and the SectionClass for the kind:

    #![allow(unused)]
    fn main() {
    pub fn shield_section(config: ShieldSectionConfig) -> impl Bundle {
        (
            ShieldSectionMarker,
            SectionClass::Shield,
            // ... kind-specific render/behavior components
        )
    }
    }

    Then register the module in crates/nova_ship/src/sections/mod.rs: add pub mod <kind>_section; and re-export <kind>_section::prelude::* in the module prelude.

  2. Add the enum variant. In crates/nova_ship/src/sections/base_section.rs, add the variant to SectionKind (grep for enum SectionKind):

    #![allow(unused)]
    fn main() {
    pub enum SectionKind {
        Hull(HullSectionConfig),
        Thruster(ThrusterSectionConfig),
        Controller(ControllerSectionConfig),
        Turret(TurretSectionConfig),
        Torpedo(TorpedoSectionConfig),
        Shield(ShieldSectionConfig),
    }
    }
  3. Section class. Add the variant to SectionClass in crates/nova_gameplay/src/damage.rs (grep for enum SectionClass). It is a LABEL, not a damage key – there is no resistance table, so there is nothing else to fill in on the damage NUMBER. How much a section takes is its health; how far a round gets through it is the travel rule, which reads Health.max and never the class. What the section LOOKS like as it is damaged is a separate, authored decision - step 8.

    The NOVA OS ship app labels sections by this enum: add an arm to the exhaustive matches code_prefix, kind_glyph, kind_description and kind_index in crates/nova_os_ui/src/ship/sections.rs, and to section_kind_label in crates/nova_os_ui/src/terminal/content.rs.

  4. Wire the section plugin. In crates/nova_ship/src/sections/mod.rs, add your plugin to the add_plugins((...)) tuple in SpaceshipSectionPlugin::build (grep for impl Plugin for SpaceshipSectionPlugin), passing the render flag like the others:

    #![allow(unused)]
    fn main() {
    <kind>_section::ShieldSectionPlugin {
        render: self.render,
    },
    }
  5. Spawn arm. In crates/nova_scenario/src/objects/spaceship.rs, add a match arm to insert_spaceship_sections (grep for it, then its match &config.kind). At minimum insert the kind bundle; add input-binding / infinite-ammo handling only if your kind needs it (see the Turret / Thruster arms for those patterns):

    #![allow(unused)]
    fn main() {
    SectionKind::Shield(shield_config) => {
        section_entity.insert(shield_section(shield_config.clone()));
    }
    }

    This is the production spawn path; see the Scenario engine for how the spaceship object and its section observer fit together.

  6. Editor placement arms. Two exhaustive matches, in two files. default_binds_for (crates/nova_editor/src/placement.rs) gives the kind its default key/pad binding, or vec![] if it takes none - model Hull for unbindable, Thruster or Turret for bindable. insert_preview_section (crates/nova_editor/src/preview.rs) inserts the <kind>_section(...) bundle beside the shared preview_section(...) recipe, plus the kind’s input-binding component if it has one.

    Nothing here computes a placement POSE. The editor mates link points (snap_placement, nova_ship::sections::link_points), so orientation comes from the two sockets, not from the kind - which is what lets a part mate the same way up on any socket. Recording the placed section into player_config.sections is likewise generic (register_preview_section), so the arms only insert.

  7. Parts gallery category + readouts. In crates/nova_editor/src/gallery/catalog.rs, add a GalleryCategory variant (with its ROW entry, label() and accepts() arms), then arms to kind_label() and behaviour(). All of them match SectionKind exhaustively, so the compiler walks you through it:

    #![allow(unused)]
    fn main() {
    // accepts
    Self::Shields => matches!(kind, SectionKind::Shield(_)),
    // kind_label - the tile's category line
    SectionKind::Shield(_) => "shields",
    // behaviour - the two or three numbers a builder picks the part BY
    SectionKind::Shield(shield) => {
        vec![("capacity".to_string(), format!("{:.0}", shield.capacity))]
    }
    }
  8. Asset prototype. In crates/nova_authoring/src/base_content/sections/standard.rs, add a SectionConfig to standard_section_prototypes() so the catalog ships a ready-to-place instance. Give it a stable snake_case id (this is what sections.get_section("...") and RON authors reference). The id is a runtime STRING that nothing type-checks: keep it a literal beside the builder, and promote it to a const in crates/nova_ship/src/sections/catalog_ids.rs only when a crate that cannot reach nova_authoring has to name it.

    #![allow(unused)]
    fn main() {
    SectionConfig {
        base: BaseSectionConfig {
            id: "basic_shield_section".to_string(),
            name: "Basic Shield Section".to_string(),
            description: "A basic shield section for spaceships.".to_string(),
            health: 100.0,
            damage_effects: DamageEffects(vec![DamageEffect::Cracks, DamageEffect::Sparks]),
            ..default()
        },
        kind: SectionKind::Shield(ShieldSectionConfig { /* ... */ }),
    },
    }

    damage_effects is a real design decision per kind, not boilerplate. The shipped rule: a hull authors nothing (defaulting to [Cracks]), anything carrying machinery adds Sparks, and a thruster adds Plume on top. Pick by what the part IS - a turret is all function and fails by sparking, a hull has nothing but material to lose. The rule the vocabulary is kept honest by: no section loses geometry, so every effect is a material or a particle. See Ship sections internals.

    If your config needs a render-mesh AssetRef, add a field to BaseContentAssets and its from_paths() in crates/nova_authoring/src/base_content/assets.rs.

    The builders do not feed the game directly: regenerate the committed RON with cargo run content gen and commit assets/base/sections/base.content.ron with the code change - the content_ron_parity test fails on drift.

  9. Example. Add examples/systems/<what it proves>.rs, modelled on the existing per-section ranges (system_attitude_hold.rs and system_thrust_and_plume.rs are the most compact), plus its [[example]] block in the root Cargo.toml (auto-discovery is off; the catalog is the source of truth) - catalog_matches_disk in crates/nova_probe_cli/tests/catalog_drift.rs fails until disk and catalog agree, and systems_ranges_assert_their_invariant_roster beside it fails until the new range has a named invariant roster. The example builds a minimal ScenarioConfig (a controller + your section), triggers LoadScenario(...), and under --features debug drives an autopilot probe that asserts the kind’s behavior end to end. Run it:

    NOVA_AUTOPILOT=1 cargo run --example <what it proves> --features debug
    

Done

The compiler is your checklist enforcer for 2-7: if it builds, every exhaustive match handles the new kind. Steps 1, 8, and 9 are the ones with no compile-time backstop – the module wiring, the catalog instance, and the runnable proof – so double-check those by hand.

Find it in the code

  • The enum and base config: SectionKind, BaseSectionConfig - crates/nova_ship/src/sections/base_section.rs; model modules hull_section.rs (minimal) and turret_section/ beside it.
  • Class label: SectionClass - crates/nova_gameplay/src/damage.rs; NOVA OS label matches - crates/nova_os_ui/src/ship/sections.rs.
  • Spawn arm: insert_spaceship_sections - crates/nova_scenario/src/objects/spaceship.rs.
  • Editor arms: default_binds_for - crates/nova_editor/src/placement.rs; insert_preview_section - crates/nova_editor/src/preview.rs; GalleryCategory - crates/nova_editor/src/gallery/catalog.rs.
  • Cross-crate prototype ids: crates/nova_ship/src/sections/catalog_ids.rs.
  • Prototypes: standard_section_prototypes - crates/nova_authoring/src/base_content/sections/standard.rs.
  • API detail: cargo doc --open -p nova_ship.

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 into GameObjectives).
  • 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 a commands.queue(move |world: &mut World| ...) inside it, the shape DespawnScenarioObjectActionConfig uses.

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.

  1. In crates/nova_events/src/lib.rs define the marker event and its info struct with the EventKind derive. 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 the Entity filter 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_events prelude (the pub use super::{...} block at the top of lib.rs).

  2. In crates/nova_scenario/src/events.rs add the variant to EventConfig (grep for enum EventConfig) and the arm to impl From<EventConfig> for EventHandler<NovaEventWorld> (grep for impl From<EventConfig>):

    #![allow(unused)]
    fn main() {
    pub enum EventConfig {
        // ...
        OnDocked,
    }
    
    // in the From match:
    EventConfig::OnDocked => EventHandler::new::<OnDockedEvent>(),
    }
  3. Fire it. Engine-driven events fire from crates/nova_scenario/src/loader/ with commands.fire::<OnDockedEvent>(OnDockedEventInfo { .. }) (see the OnStart site in loader/lifecycle.rs, OnUpdate in loader/clock.rs, and orbit-lifecycle/the lock events in loader/trackers.rs); object-local events (an area entering/leaving) fire from the object’s own observer, the way objects/area.rs fires OnEnterEvent from its own trigger. A kind may also fire from a system it owns: objects/asteroid_carve.rs fires OnDestroyedEvent out of carve_asteroid_fields when 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.

  1. Define the config struct and its EventFilter<NovaEventWorld> impl. filter returns a bool and may read world (variables) and info (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()
        }
    }
    }
  2. Add the variant to EventFilterConfig (grep for enum EventFilterConfig) and the arm to impl EventFilter<NovaEventWorld> for EventFilterConfig (grep for impl 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),
    }
  3. Export the config struct from the module prelude (the pub use super::{...} block at the top of filters.rs).

  4. Make it authorable in the editor (see Two surfaces author the vocabulary): derive Reflect on the config, tag any string that names something with the Names attribute, and add the FilterChoice variant in crates/nova_editor/src/event.rs - ALL, label, stem, operands, stock, plus the filter_choice, filter_config and filter_config_mut arms. 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).

  1. Define the config struct and its EventAction<NovaEventWorld> impl. fn action(&self, world: &mut NovaEventWorld, info: &GameEventInfo) mutates the seam and nothing else - use world.insert_variable, world.push_objective, world.next_scenario, or world.push_command(...) for world access. Anything needing an id -> Entity lookup queues a commands.queue(move |world: &mut World| ...) inside the pushed command, scoped with With<ScenarioScopedMarker> (a raw id match would also hit ship sections that carry EntityId).

    #![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));
        }
    }
    }
  2. Add the variant to EventActionConfig (grep for enum EventActionConfig) and the arm to impl EventAction<NovaEventWorld> for EventActionConfig (grep for impl EventAction<NovaEventWorld> for EventActionConfig):

    #![allow(unused)]
    fn main() {
    pub enum EventActionConfig {
        // ...
        VariableClear(VariableClearActionConfig),
    }
    
    // in the action match:
    EventActionConfig::VariableClear(config) => {
        config.action(world, info);
    }
    }
  3. Export the config struct from the actions/mod.rs prelude block.

  4. Make it authorable in the editor, as in recipe 2: derive Reflect, tag the naming strings, and add the ActionChoice variant in crates/nova_editor/src/event.rs - ALL, label, stem, stock, plus the action_choice, leaf_config and leaf_config_mut arms. Only Sequence holds 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.rs declares RigidBody::Static (the base bundle supplies no body), and light.rs is a pure-render kind: it splits config from component with an Add observer so the render flag can skip the Bevy light entirely for headless tools. Note that scene lighting itself is authored content - a scenario with no Light object renders black, so any new example or fixture that renders needs one.

  1. Add the type-name const to crates/nova_events/src/lib.rs, beside EntityTypeName and the other *_TYPE_NAME values, 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 on nova_scenario to name a kind (CONVENTIONS, Nova 5).

    #![allow(unused)]
    fn main() {
    /// [`EntityTypeName`] value for an authored mine.
    pub const MINE_TYPE_NAME: &str = "mine";
    }
  2. Create crates/nova_scenario/src/objects/<kind>.rs. It holds a config struct, a marker component, a <kind>_scenario_object(config) -> impl Bundle builder, and (optionally) a Plugin for any observers/systems the kind needs. The bundle carries the marker plus an EntityTypeName; the shared base_scenario_object (id, name, transform, visibility, ScenarioScopedMarker) is added by the spawn path, not here. It deliberately carries NO body - each kind declares its own RigidBody (the asteroid adds Dynamic + 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};
    }
    }
  3. Register the module in crates/nova_scenario/src/objects/mod.rs: add pub mod <kind>;, re-export <kind>::prelude::* from the mod.rs prelude, and if the kind has a plugin add it in ScenarioObjectsPlugin::build (like AsteroidPlugin, which takes render).

  4. In crates/nova_scenario/src/actions/spawn.rs add the variant to ScenarioObjectKind (grep for enum ScenarioObjectKind) and the spawn arm in impl 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:

RecipeWhat the editor needs
Event kindNothing. 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.
FilterReflect + Names on the config, and a FilterChoice variant with its stock value.
ActionReflect + Names on the config, and an ActionChoice variant with its stock value.
Object kindReflect + 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 the EventKind derive - 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 under crates/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).

Giving a generated ship a front

Design note, written against examples/playable/shared/wfc.rs and the wfc_ships row it collapses.

The complaint it answers: looking at any render of the wfc_ships row, drives point in several directions on the same hull. The generator places parts by mating rules alone. Nothing knows a thruster belongs at the back, a bridge on top, or guns where they can bear.

One of the recommendations below is already implemented and rendered, because it turned out to be twelve lines. The rest is a proposal.

1. The actual cause, which is narrower than “no sense of layout”

Two facts about the generator, together, produce the whole symptom.

A drive’s direction is not a choice the generator makes. The thruster section carries ONE socket, on its forward end, and its exit normal is +Z in its own frame. So the face it is bolted to IS the direction it exhausts. A drive bolted to the roof fires up; one bolted to the transom fires aft. Nothing “chooses badly” - the collapse never chooses at all, it just finds a hull face.

The mating rule is BINARY and cannot hold a fact about a whole ship. compatible(tiles, here, face, there) reads one cell and its neighbour. “Aft” is not a property of any pair of cells; it is a property of the grid. A binary constraint cannot express it, ever, no matter how the tileset is authored.

So the symptom is not a missing heuristic. It is a category error: the file was asking a local rule to carry a global fact. Everything below is a way of putting that fact somewhere a local rule can see it.

Worth saying plainly what is NOT broken. Guns already point somewhere sensible, and for a reason: exit clearance forbids a muzzle whose lane is fouled, so a mount can only survive standing proud of the hull with space in front of it. Clearance is doing the “where they can bear” job already. Bays likewise - a broadside tube is a real warship, and a bay firing into its own hull is already illegal. The drive was the one part whose direction was both free and wrong.

2. What the grid already gives us to hang this on

More than expected. The generator is not orientation-blind - it is orientation-blind about parts while being quite opinionated about shape.

FactWhereWhat it already means
LENGTH = 11 on zHULL_GRIDthe ship’s long axis
HEIGHT = 5 on y, KEEL_ROW = 2HULL_GRID, KEEL_ROWup, and a middle
x = 0 is the mirror planeHULL_GRID.origin.x = 0.5port/starboard
VACUUM_BOW_TAPER = 24.0hull_vacuum_weightz = 0 is the BOW
VACUUM_STERN = 9.0hull_vacuum_weightthe last row is sparse
seed_keelcollapses the spine by handa connected structure to grow on
keel_prototype(LENGTH / 3)the bridge, forward of centrea front, already

The answer to “does keel_component / the mating structure give a natural axis to hang this on” is yes, and it is not the mating structure - it is the KEEL. seed_keel already hand-collapses eleven cells before the generator gets a say, and keel_prototype already puts a bridge forward of centre. The precedent for “decide the big thing up front, let the collapse fill in around it” is in the file, working, with a doc comment explaining why. Everything in this note is that same move applied one more time.

What the grid does NOT give: any asymmetry in y. off_keel is x + |y - KEEL_ROW|, symmetric about the keel row, so a hull’s deck and its belly are weighted identically. A ship generated here has a front and a back but genuinely has no top.

3. What comparable generators do

Read rather than re-derived. Sources at the end.

Nobody makes a local constraint carry a global fact. The consistent answer across practitioner writeups is: decide the coarse thing FIRST, by other means, then let the constraint solve fill in detail. Boris the Brave calls this driven WFC and states the cost plainly - the algorithm already starts with a set of possible tiles per cell, so filtering that set before the solve is free. His worked example is Townscaper, where the player’s painted solidity decides which tiles may go in each cell and WFC never sees a global decision at all.

Per-cell BANS, not per-cell weights. This is the distinction that matters and it is easy to get wrong. Weights bias; they do not forbid. A low-weight drive at the bow still lands at the bow eventually, and worse, tile weights in the classic formulation are global frequency hints - vary them per cell and the entropy heuristic has to move from a count to a proper Shannon entropy over the surviving weights or the cell ordering stops meaning anything. A unary domain filter has none of that cost. This is standard constraint-solving practice, not a trick: unary constraints are handled by adjusting variable domains before the solve starts.

Buckets by normal direction, after the fact. a1studmuffin/SpaceshipGenerator (MIT), the best-known procedural spaceship, builds a hull by extruding front and rear faces and then categorises every face by its normal and rolls detail per category - engines on rear faces, antennae on front and top, weapons on the sides. Two things to take: detail follows orientation, and there is a GUARANTEE GUARD - the rear-face rule fires an engine if the roll passes or the engine list is still empty, because a pure probability table produces engineless ships.

Regions, if bans start causing contradictions. Merrell’s model synthesis “modifying in blocks” - solve overlapping blocks with their borders constrained to what is already placed, and restart the offending block rather than the whole output - is the standard answer, and Caves of Qud ships a version of it, running WFC with different settings on subsets of the map. This is the escape hatch, not the starting point. It is worth knowing that our collapse has NO backtracking and cannot get one cheaply, so anything that raises the contradiction rate is expensive here in a way it is not elsewhere.

Grammars are not automatically the semantics fix. Shape and graph grammars express “door on the front facade” naturally, because a grammar’s scope is an oriented box. But Merrell’s own graph-grammar work targets LOCAL SIMILARITY, the same goal as WFC, and does not claim to encode front/back/top. Swapping solvers does not buy the fact; putting the fact in the right place does.

4. Ranked recommendations

Cost against effect. The first is done; the rest are not.

1. A per-part AIM, as a unary constraint. DONE, RENDERED

Part gains aim: Option<usize> - the only face this part may fire through - and hull_domains strikes any tile that disagrees. The thruster gets Some(AFT); everything else keeps None.

  • Cost: one struct field, a four-line predicate, one line in hull_domains. Cannot empty a domain, because VACUUM is compatible with everything and is never struck, so the no-backtracking collapse is not put at risk.
  • Effect: every nozzle on every ship points aft. This is the whole reported complaint.
  • Price, measured over 12 seeds: drives 258 -> 74. That is not a rounding error, it is 70% of the engines, and the reason is structural rather than a tuning miss: a drive has one socket and five blind faces, so it can only stand where EXACTLY ONE neighbour is solid, and fixing that neighbour to be the forward one means it can only stand on an aft-facing proud surface. On a hull whose only aft-facing surface is the transom, there are very few such cells. Raising the weight alone does not buy them back: at ten times the weight and no seed, the row still came back with 40 drives over 12 ships. Supply, not price, is the binding constraint - which is exactly why the next item is not optional.

2. Seed the drive deck, the way the keel is seeded. DONE, RENDERED

seed_stern hand-collapses two cells before the roll: a hull block beside the last keel cell, and a nozzle bolted to its aft face. The mirror makes that a pair either side of the centreline. seed_keel now stops one cell short of the transom so the seam cell beside the drive is free - a keel cube there would press a socket into the drive’s blind flank.

  • Cost: fifteen lines, in the shape of the function above it.
  • Effect: every ship has an engine at the back of it, guaranteed, and the seeded pair MAKES the aft-facing surface the roll then fills the transom around. Item 1 says where a drive may not go; it cannot conjure the place where it may. The two are one change.
  • Measured, 12 seeds, with the drive weight taken 3.2 -> 6.4 to pay for the rule: hull 1774 -> 2000, drives 258 -> 102, bays 96 -> 74, mounts 92 -> 62, bridges 46 -> 52. Per ship that is 21.5 scattered drives becoming 8.5 in one stern bank. Weapons are down about a third, which is a weight question and the note under item 6.
  • Rendered at a matched camera over the default row. The ships gained a readable stern: a bank of nozzles all firing the same way, with the hull running forward from it. This is the single biggest change to how the row reads of anything in this note.

3. Give the hull a deck and a belly. MEASURED, NOT LANDED

off_keel is symmetric in y, so a ship has no top. Weight the two directions differently - |y - KEEL_ROW| times DECK_TAPER above and BELLY_TAPER below - and the hull fills out above the keel and tapers under it.

  • Cost: one line and two constants.
  • Effect, measured over 12 seeds at 0.6 / 1.6: hull 1774 -> 1968, drives 258 -> 212, bays 96 -> 78, mounts 92 -> 90. Rendered, and I did not land it. It makes the ships denser and blockier rather than more oriented: one of the three read distinctly better, one read as a brick, and the owner has said on the record that the pointy, faceted vocabulary is wanted. A silhouette change is a taste call for the owner, not a bug fix, and this one is not clearly an improvement. Left here with its numbers so the next person does not have to measure it again.
  • If it IS wanted, the honest version is probably not this: it is a y-dependent keel row, or a superstructure seeded on top of the keel the way item 2 seeds the stern. Which is item 4.

4. Seed a superstructure, not just a bridge cell

keel_prototype puts the controller at y = KEEL_ROW, mid-height, buried inside the hull. A bridge that reads is a bridge you can SEE: on top, forward of centre, standing proud.

  • Cost: a second seeded run of cells, in seed_stern’s shape - two or three hull cells at y = HEIGHT - 1 around z = LENGTH / 3, with the controller on top of them.
  • Effect: a ship gains a recognisable island, which with the derived skin becomes a plated superstructure rather than a lump. It also gives the decoration scatter’s PlateFacing::Up rules somewhere meaningful to fire.
  • Risk, and it is real: a seeded tower is a stud, and erode_studs exists to take those off. It would need the same treatment the keel gets - either enough seeded neighbours to pass SPIKE_SUPPORT, or an exemption. Budget a couple of hours, not ten minutes.

5. Zone the grid, and give each zone its own part list

The general form of items 1-4: split z into bow / midships / engineering bands (the vacuum taper already computes the signal) and ban part families per band - no drives forward of the engineering band, no bays in it, bridges only in midships-top. Same mechanism as item 1, a table instead of a formula.

  • Cost: a band function and a table. Maybe forty lines.
  • Effect: the ships get an internal LAYOUT rather than a uniform texture, which is the difference between “a hull with parts on it” and “a hull with a bow, a waist and an engineering section”.
  • Do this only if items 1-4 are not enough. Every band is more domain filtering, and filtering shrinks the solution space while local propagation still cannot see a dead end coming. This collapse has no backtracking. If bands start producing contradictions the answer is modifying-in-blocks per band, and that is a real piece of work.

6. Re-tune the weights, once, after all of the above

The record already says weights have to be tuned together and read together, because the parts compete with each other rather than only with vacuum. Items 1 and 2 changed the competition: drives now bid for a small set of aft cells instead of every exposed face, so the surface freed up went to hull. Bays and mounts came down a third and nobody asked them to.

  • Cost: a sweep, and the harness for it exists (--ships 12 prints the histogram).
  • Do it LAST. Tuning weights against a layout that is about to change is wasted work, and it is how the previous round of numbers got measured twice.

5. What I would NOT do

Do not put the fact in the tileset. It is tempting to author two thruster prototypes, an “aft drive” and a “manoeuvring thruster”, and let mating sort them out. It cannot: mating is binary and the distinction is global, so both would still land anywhere. It would also put a generator concern into shipped catalog content that the editor and every scenario have to carry.

Do not add a scoring-and-rejection pass. Generate N ships, score each for “engines at the back, bridge on top”, keep the best. It is the obvious answer and it is a trap: the cost is N collapses per ship, the failure mode is that the SCORER quietly becomes the real designer while the generator’s own rules stop being the thing that produces the design, and any weighting inside it is invisible in a way a per-part field is not. The one practitioner example I found of scoring generated output optimises the generator’s PARAMETERS rather than picking among outputs, which is a different and much cheaper shape. If we ever want this, that is the version to want.

Do not rewrite the collapse as a grammar or a graph rewrite. It would express front/back/top naturally, and it would throw away the thing this generator is actually for: the adjacency rules ARE the catalog’s link points, so a hull the generator draws is a hull a player could have built. That property is the whole point of the example and no amount of layout sense is worth it.

Do not make the exit-clearance rule directional. It is correct as it stands, it is shared with the editor, and a version of it that knows about “aft” would put a generator’s taste inside a rule a player’s ship is judged by. Aim belongs in Part, where it is one file’s opinion; clearance belongs in nova_ship, where it is physics.

Do not reach for backtracking. Every item above is a unary domain filter and none of them can empty a domain, because vacuum is compatible with everything. That property is worth defending: a collapse that cannot fail needs no restart loop, no retry counter and no failure budget. Any proposal that costs it should have to argue for itself.

Sources

Verified from the primary source: