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.
Units: meters, and the one engine seamnova_events, nova_uicrates/nova_events/src/units.rs, crates/nova_events/src/scale.rs, crates/nova_ui/src/units.rsMeters, METERS_PER_UNITEverything authored, named as a constant or shown to a player is SI; Bevy, avian, meshes and the build grid count in world units at 10 m each. to_engine/from_engine are the only crossing and every call site is a documented boundary; nova_ui::units formats a readout without converting. The whole rule, and what stays engine-side: Architecture.
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 WORLD unit, so per 1000 m^3 - an inverse volume, and engine-side for that reason); 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; an unarmed ship instead goes this way on the last working thruster it ever had (HadThruster); the hull may survive.
Procedural hulls: the collapse, the plannova_wfccrates/nova_wfc/src/lib.rs, crates/nova_wfc/src/collapse.rs, crates/nova_wfc/src/tiles.rs, crates/nova_wfc/src/plan.rsTileSet, WfcPlanTileSet::build reads a WfcPlan against the section catalog; hull() collapses one seed into a ShipDesign and runs it through the game’s own content lint before handing it out. The plan is CODE, not content: WfcPlan::standard_hull is the shipped one, and each caller (the editor’s Generate block, wfc_arena) builds its own from that plus its own UI rows. Grid geometry, seeds and zones: crates/nova_wfc/src/grid.rs. Whether a part can stand on the grid at all is nova_ship::cell_grid_fit, and nova_ship::oriented_part reads it as grid cells at one cube rotation (crates/nova_ship/src/sections/cell_grid.rs); the open-world part snapshot and ship layout (crates/nova_world_base/src/ship_layout.rs) read both too.
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). A style authors a two-surface StylePalette and, per fixture, a three-word FixturePlacement (region, density, orientation) that skin_style expands privately into the engine’s own ScatterRule; 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).
The spinal lance: commit, charge, shot, recoilnova_shipcrates/nova_ship/src/sections/railgun_section/mod.rs, crates/nova_ship/src/sections/railgun_section/firing.rsRailgunSectionPlugin, RailgunChargeThe hull aims it. charge_and_fire_railgun runs the whole cycle in one fixed-step system; the slug is a Pierce round with layers: u32::MAX, so slug_power alone bounds it. The AI’s crude commit envelope: input/ai/railgun.rs (AIRailgun).
The rake: a slug that widens its cutnova_gameplay, nova_shipcrates/nova_gameplay/src/rounds.rs, crates/nova_ship/src/sections/railgun_section/firing.rsRoundRake, sweep_rakingAuthored rake_radius inserts RoundRake; three passes per step - the tip arms bodies it hits, a sphere trailing it sweeps each armed body’s colliders, contacts charge by depth then axis outward out of the one budget. Measured in examples/systems/system_railgun_lance.rs.
Docking: the candidate pair, the joint, the breaknova_shipcrates/nova_ship/src/sections/docking_section/port.rs, crates/nova_ship/src/sections/docking_section/admission.rs, crates/nova_ship/src/sections/docking_section/connection.rsDockingPorts, DockAdmission, DockingConnection, DockingSystems::ReleaseDOCK names two SHIPS; a new dock needs both a port candidate (face gap between RETRACTED faces, opposed axes, relative speed, graded on the STRICTER of the two envelopes and tie-broken on EntityId) and DockAdmission, which the target refuses while Hostile, Enemy, answering fire, or fighting (a neutralized target always admits). One FixedJoint with an explicit anchor and basis holds the pose they met in; nothing writes a velocity. One hull drives the pair: DockingConnection.helm (DockedHelmType) picks it, DockedShip.drives gates every actuator on the other root, and DockedAssembly gives the driver the combined mass, centre of mass and inertia. DOCK again from either hull, or the player hitting a docked Neutral, are the pilot-facing ways out.
The bore sightnova_hudcrates/nova_hud/src/bore_sight.rsBoreSightPluginThe lance’s line of fire in world space, walked through the same pierce_remainder as the round; a ring per section the shot would destroy; gated on WeaponsHot, dimmed on an empty magazine.
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.rsShipCapabilities, AutopilotThe ship ROOT carries what it is permitted to do; the flight layer flies the maneuvers (NovaFlightPlugin); PDControllerPlugin is the attitude loop, and a controller section is the attitude hardware it needs.
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; only anchored planets and authored anchors publish one. Ships and asteroids feel their pull.

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, planet, 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
The TAB interface and the : command modalnova_command, nova_interfacecrates/nova_command/src/commands.rs, crates/nova_interface/src/pane.rs, crates/nova_interface/src/lib.rsInterfacePlugin, InterfaceSystems, CommandsSystemsPure command language and terminal model in nova_command (COMMAND_CATALOG, CommandTerminal); the Tab interface with its Map, Ship and Inventory panes, and the : CRT modal, in nova_interface.
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 role: Backdrop scenario from the menu’s shuffled rotation (MenuBackdropRotation); a backdrop’s BackdropDone makes advance_menu_backdrop cut to the next one. NOVA_MENU_BACKDROP pins the first one for capture.
Training handbook: lessons, field notes, progressnova_training, nova_menucrates/nova_training/src/catalog.rs, crates/nova_training/src/validate.rs, crates/nova_menu/src/training.rs, crates/nova_menu/src/training_store.rsTrainingCatalog, TrainingProgressA lesson is CONTENT (Content::Lesson): register_bundles merges base + mods into TrainingCatalog, and the menu draws whatever it is handed. Practice may only name a role: Lesson scenario and proven_by any launchable one; both are lint_lesson, at lint and then at load. Progress is a SECOND file beside settings.ron (TrainingProgressPlugin): opening a lesson reads it, and only a Victory on a scenario its proven_by names completes it. Field notes are derived from the same lessons (facts.rs) and drawn on three surfaces - the menu’s bottom-left notice corner beside the first-launch offer, the boot screen (compiled boot_field_notes, before any catalog exists) and the scenario load screen, which prefers a note from a lesson that names the scenario. Each corner notice carries its own switch (TrainingPromptSetting, FieldNoteSetting): the card writes it, Settings > Interface brings it back, and neither touches the loading screens. A lesson’s demonstration is real footage at assets/base/training/<id>.webp, recorded by the lesson_* capture producers (scripts/capture-lesson-media.sh) and cut by the grid the LESSON authors; every base lesson has a producer, and one authored without one keeps scripts/gen-lesson-media.py’s placeholder, which never redraws over captured footage.
Scenario rolesnova_scenariocrates/nova_scenario/src/loader/mod.rs, crates/nova_scenario/src/lint/scenario.rsScenarioRoleChapter (a level, the only picker row), Backdrop (menu scenery, poses its own camera, reports BackdropDone), Lesson (a handbook practice range), OpenWorld (the open world’s bootstrap, the base bundle’s New Game start). A campaign may list only chapters.
Shared UI themes and widgetsnova_uicrates/nova_ui/src/theme/mod.rs, crates/nova_ui/src/widget/mod.rsNovaUiPluginModdable UI themes (UiThemeConfig -> ActiveUiTheme) 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: benching generated hullsexamples, nova_wfcexamples/playable/wfc_arena.rs, examples/playable/wfc_arena/stamps.rsarena_tilesFields collapsed hulls against each other. The collapse itself is nova_wfc (see The ship); the arena names a spinal gun as its plan’s keel.bow_gun and stamps a large-drive stern no single plan can express. Lobby, pause and result flow sit beside it 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 tutorial    # 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 + ship parts + 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), railgun_wake_bench (the slug’s ionized wake at three speeds, 1-3 pick the lifetime policy, the arrows tune it, [ ] slow it down), 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), compare_asteroids / compare_planets (the number keys re-dress the focus subject) and asteroid_kinds / planet_types (the whole authored range of each, side by side). Last, the mission-scale pair: first_shift_map is a fixed belt layout with a route over it - the most populated authored scene there is - and first_shift_ships poses eight shipped hulls in a row. 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, at the speed its own size asks for), 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) and system_railgun_lance (the spinal gun: a commit that outlives the trigger, a charge cue, one slug raking six plates in a single step, and the recoil that shoves the firing ship). Beside them, the ranges that read a figure off the HULL rather than off a constant, each staged on a skiff and a capital at once: system_hull_scaling (the two reference hulls’ live inputs and what the game derives from them - lock range, the fire gate, the size a hull burns at), system_collision_damage (a ram at three closing speeds, and the docking speed that costs nothing), system_ai_combat (what an AI ship does when it fights) and system_ai_evade (what it does when a gun is held on it: three jink legs, each the size of the hull flying it). 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_cinematic (a SCENE: the prompt and title card it puts on the HUD, the pause that freezes the way out, the skip the player presses, and the two endings the scenario hears in order), system_outcomes (die -> the Defeat overlay -> Retry -> a clean reload -> kill -> the objective and the CHECKPOINT -> Continue -> the chained scenario), bug_outcome_pause (who owns the screen and whether the world is running behind them: alt-tab pauses a fight and coming back does not resume, an outcome landing over the pause menu takes the screen, and a timed advance taken while the player is away hands its pause to the pause menu without one unpaused frame), bug_refused_scenario (a chapter the content gate refuses ends the one it replaces, cursor freed and Main Menu working), bug_failed_assets (a broken optional mod is quarantined and reported, and a broken base asset is fatal and says so), 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), bug_menu_fallback (a menu with no clean backdrop to draw ends the scenario it came from and stands on one bare camera) and system_scenario_picker (the Scenarios picker: its pane split must not depend on the selection - real fonts, real taffy, which a headless unit rig cannot measure at all - and the row a pointer selects and plays is the scenario that comes up once the atomic load gate releases), 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), system_interface (the Tab interface and the : modal over it, driven with keys only: TAB, M and : must return through Escape to the same pane with the clocks frozen and no pane or flight action firing on the transition frames), system_command_shell (the modal over flight, the pause menu, the main menu and the interface, including the rebind gate that keeps : shut while a ship section waits for its key) and system_headless_map_goto (a click on a Map blip in window space, then GOTO, asserted on the ship’s autopilot with no GPU). 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. stress_hull_collapse is the odd one out: it fires ONE siege slug down the long axis of a 1296-cell hull and asserts the corridor that left it and that every piece it shed went physical, then RECORDS the collapse frame cost and the debris peak instead of capturing frames, because a one-shot walk cannot fill the baseline window. 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 - system_scenario_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 shot (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 interface and the command shell (screenshot_interface, screenshot_command_shell), 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, screenshot_comms), the spinal lance (screenshot_railgun) and the flight computer around a real well (screenshot_orbit, screenshot_goto_burn, screenshot_flip_burn). The loop_* producers write only video; recording is not their privilege, though - a loop_start/loop_end pair records from any harnessed example, so screenshot_railgun, screenshot_editor, wfc_arena, system_torpedo_launch, railgun_wake_bench, stress_hull_collapse and system_lock_line_of_sight each ship a loop beside their stills or their readings. What a run makes is decided by scripts/capture-web-media.sh (loops) and scripts/gen-web-screenshots.py (stills), which name the producer behind every file they package - with one deliberate gap. The three before/after splits are composed BY HAND from a capsule scene of the old release and a loop of the new one, so they enter the media script as IMPORTS: it names the split and the producer of its new half, but never runs that producer and never names the news-0130-{sections,belt,death}-after.webm file it writes. loop_sections_compare, loop_belt_compare and loop_death_compare are therefore run by hand, and the module doc on each says how. 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. Every run of that script first checks the producer columns against the workspace’s [[example]] names and refuses to do anything if one no longer answers, and scripts/capture-web-shots.sh stops on an empty list rather than packaging the shipped bytes back over themselves.

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 can run any cataloged example - cargo run --features debug probe run systems (or screenshots, or playable) runs one category alone, and --all runs the whole catalog. probe-full.yaml runs a curated 24-example set in five subject shards on a release and on manual dispatch; a pull request runs a seven-example smoke subset. Neither CI runtime suite runs the whole catalog. 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 and fly on the DEFAULTS: 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

  • and makes SettingsStorePlugin inert, so the run loads no settings.ron, saves none, and applies no window mode. Both read the same pair through nova_gameplay::harness_env_active. The volume SETTING is untouched (the mute is an output mask; persistence and the settings menu never see it). 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.

A HAND-RUN example is not silent and not pinned: it reads the store like the game does, so with no settings.ron saved it opens in the default window mode, which is now borderless fullscreen. Save Windowed once from the game’s Graphics tab (or window windowed in the command shell) and every example follows - examples read that file, they never write it.

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; the release’s curated probe suite runs 24 selected ranges, and a pull request runs seven of those. Other ranges compile in CI but need an explicit probe run for runtime proof. 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 tutorial           # 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 so are the menu backdrops - a superset of the rows the Scenarios picker shows. The editor’s own Play range is not in it: it is the open document, not installed content, and the only way to it is Play in the editor.
  • 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.
  • It is a launch REQUEST, spent on the first boot. Back to Main Menu re-reads the content on the way out, and the second pass through Loaded finds no request left to open - so the flag opens its scenario once and then hands the session over to the menu, instead of being a door onto itself.
  • 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 and story-mod *.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 generated-ship part check - each bundle’s section catalog, with base and its transitive dependencies, must build one nova_world_base::ShipPartSnapshot: positive finite part stats, a docking capture angle under 90 degrees, a section id overlaid only along a dependency chain, a usable hull, controller, thruster, cargo intake and docking port, a weapon or thruster that fires straight out of one of its own faces, and no socket on the face a weapon, thruster, cargo intake or docking port fires, opens or docks through. Each fault is reported once per owner: the bundle that authors it, or else each bundle whose catalog raises it;
    • 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 and package in one command:

nix develop --command scripts/capture-web-shots.sh              # every producer
nix develop --command scripts/capture-web-shots.sh screenshot_railgun  # or a few

It stages into target/shots, runs each producer under Xvfb, and then packages with scripts/gen-web-screenshots.py. It is the stills half of a pair: scripts/capture-web-media.sh does the same for the webm loops, and scripts/capture-lesson-media.sh the same again for the training handbook’s demonstrations - the lesson_* producers, each writing one lesson’s still or 4x5 sprite sheet into assets/base/training/<lesson>.webp. That one ships WEBP, not PNG: the Lessons pane draws a demonstration about 900 logical pixels wide, so a sheet carries 960x540 cells (3840x2700 in all), and at that size a lossless sheet is about 1.6 MB against about 150 KB encoded. Every lesson the base handbook authors has a producer; one authored WITHOUT one keeps the placeholder scripts/gen-lesson-media.py draws - lossless WebP, at the lower resolution it is drawn at - and that generator REFUSES to redraw over captured footage, so the two can be run in either order. Both point NOVA_MODDING_CACHE_ROOT and NOVA_CONFIG_ROOT at the capture sandbox and stamp a base-only mod set BEFORE EACH producer, so neither an installed mod nor a producer that enables one on purpose (screenshot_scenario_picker does) can dress the next shot.

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 webmods/the-ledger/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 interface 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 tutorial       # a SCENARIO by id, no example involved
cargo run --features debug probe scenario assets/base/scenarios/tutorial.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. settings.ron is belt and braces there: every probe child run carries NOVA_AUTOPILOT, which makes the settings store inert, so a run reads no settings.ron at all and writes none. The sandbox is what isolates the mod state. 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 and nothing more (manual flight is Newtonian, so no speed is absurd), 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_cinematic eight scene 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. A --features debug build appends the short commit hash (0.12.0+9da8b055, or +unknown outside a checkout), so the status bar names the revision. The build script tracks .git/HEAD and its ref, so a new commit restamps it.
  • 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. The macOS job writes CFBundleShortVersionString and CFBundleVersion from the tag, then refuses to create the DMG unless Info.plist declares the copied nova-protocol executable. The workflow can also be re-run via workflow_dispatch with a version input.

Deploying a release to itch.io

The manually dispatched deploy-itch workflow (.github/workflows/deploy-itch.yaml) uploads an existing GitHub release to alexjercan/nova-protocol. It never rebuilds the game. The job downloads the four assets named above, and scripts/prepare-itch-upload.sh verifies and extracts them into the stable Butler channels windows, osx, linux, and html5. On macOS, hdiutil mounts the release DMG and ditto preserves the app bundle metadata. On Linux, the Nix development shell supplies 7-Zip to extract the HFS+ image; the script restores the declared app executable’s mode. Windows and web ZIPs use ordinary unzip. The macOS preparation checks that CFBundleExecutable names a real executable, and the web preparation requires index.html and the wasm file at the upload root. Production CI stays on macOS and uses the native path; the Linux fallback makes local preparation and preflight available without Apple tools.

Configure the protected itch-production GitHub Environment before the first run:

  • secret BUTLER_API_KEY: an itch.io API key for the alexjercan account;
  • variable ITCH_TARGET: alexjercan/nova-protocol;
  • variable BUTLER_VERSION: the exact Butler release to install;
  • variables BUTLER_SHA256_DARWIN_AMD64 and BUTLER_SHA256_DARWIN_ARM64: the SHA-256 sums of that release’s official macOS archives.

Use the Butler download documented by itch.io to choose the version, download each macOS architecture archive from https://broth.itch.zone/butler/darwin-<arch>/<version>/archive/default, and record its shasum -a 256 output in the matching Environment variable. The workflow refuses a floating Butler version or an archive whose checksum does not match.

After the GitHub release and Pages deployment are complete, keep the itch.io page in Draft and run:

gh workflow run deploy-itch.yaml -f tag=vX.Y.Z
gh run watch

The protected Environment supplies the explicit approval boundary. The run records release checksums and the prepared file inventory as an Actions artifact. Confirm itch.io detected each platform, mark html5 playable in the browser if needed, and launch all four channels on clean target systems. Only then change the page from Draft to Public. A later Butler push updates a public channel immediately, so a failed or mismatched channel blocks another deploy.

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).

Comic deployment

npm --prefix web run build:story builds only released comics into web/dist-story/story/. Its local scripts, images, favicon, and font assets stay under /story/. Shared theme/header sources are compiled into that snapshot; unrelated website pages are neither built nor published. The shared Google Fonts import remains external. Normal serving still uses the complete website and labelled drafts; there is no second development server.

Pushing a comic-* tag starts deploy-comic. A manual run accepts an existing comic tag for a retry or rollback. The workflow resolves the tag to a commit, builds once, and passes the artifact and exact commit to its publisher. The tag names a library snapshot, not a filter: publication metadata selects episodes. Game vX.Y.Z tags and version numbers remain separate. The comic authoring README owns the publishing procedure and first-rollout checklist.

The full Pages workflow uses build:site, which does not execute or emit comics. Both publishers use scripts/deploy-pages.py. It fetches the latest gh-pages under a shared github-pages-publish job lock, builds a temporary Git index and working tree, and commits a scoped merge without changing the source checkout’s index. A comic deployment replaces only story/; a site deployment preserves it. Both preserve CNAME and ensure .nojekyll. Non-fast-forward pushes fail. Pages hosting must use the gh-pages branch root. Both jobs explicitly request and wait for a Pages API build with a write-scoped Pages token.

Bootstrap the new comic directory before a site-only deployment can remove old root-level reader files. The guard requires story/release.json, which records the comic tag and source commit. Use the current site’s workflow, not a workflow from an older game tag that predates the ownership split. Serial publishing is not a FIFO release queue; wait for one deployment before starting another.

npm --prefix web run test:deploy checks isolated builds with released/draft fixtures and local bare-Git publishing with mocked HTTP. It never publishes to GitHub. Normal npm run build still builds the complete released-only website for checks; it is not the input to either scoped publisher.

Writing a story comic

web/src/comics/ is the single home for maintained comic sources. Each season has comic.json; each episode has episode.json, an ordered episode.ts, one TS file per page under pages/, and Python panel drawings under art/. The directory names provide /story/season-1/episode-1/, with page ids in the fragment. Shared faces, ships, and colors remain in scripts/nova_illustration/. web/src/comics/README.md owns the DSL and build contracts. Public exporters must not import task files.

/story/ lists seasons only, in reading order. Each season has one illustrated row with its title, summary, selected episode count, and labelled local draft count. Click it to open /story/<season>/, which lists that season’s episodes in authored order. Each episode has one clickable row and one thumbnail; drafts show how many pages are illustrated. There are no duplicate reading shortcuts or repeated season cover above the episodes.

The season page returns to the library through Story. The reader returns to its episode list through the season title, such as Season 1; Pages opens its page chooser. The phosphor shell surrounds full-color art.

Run npm --prefix web run serve and open /story/ at the printed loopback URL. Normal local development includes labelled drafts. Source edits regenerate pages and reload the same page fragment. It uses web/.cache/story/development/site, not web/dist, and does not serve stale files from a prior disk build. scripts/serve-web.sh starts this same server with the game and portal.

npm --prefix web run build always selects released episodes, even with webpack in development mode. Serving with --mode production also excludes drafts. Selection happens before generation: an excluded episode’s code never runs, and none of its routes, assets, dialogue, or script enters the output. Only referenced assets are emitted; comic directories are not broad copy/import inputs. With no released episodes, the public archive has a plain empty state.

Every metadata field is required. comic.json gives the unique positive season sequence, title, summary, and ordered episode directory ids. episode.json gives title, summary, cover scene id and alt text, TS entry filename, explicit Python art-module paths, complete pageCount, and publication (draft or released). Unknown fields, missing sources, unregistered directories, duplicate ids, and invalid generated data fail. Released episodes must form a prefix of reading order and have exactly their authored page count. A season can contain released episodes and the next draft. Publishing changes metadata after approval; sources stay in place. Comic release remains independent of campaign implementation.

The TS page language owns action, dialogue, screen labels, narrative location cards, and page layout. Dialogue uses stable ids; balloon placement is keyed separately. Unillustrated pages remain TS scripts without fake art references. The shared build engine derives reader definitions, transcripts, and a reading copy named SCRIPT.md in the generated cache. That Markdown is not another maintained source. Unillustrated story text never enters a reader manifest.

Python art modules register named Scene functions. The common builder exports only referenced standalone panel SVGs; it neither draws nor strips dialogue. Blank named text slots preserve labels’ painter order under hands and equipment. Raw SVG is checked before export, and the browser checks it again on import. comic-panels.ts composes the page frame, panels, labels, cards, and measured balloons. comic-renderer.ts owns the DOM; ComicPlayer in story-reader.ts owns fitted playback, contents, deep links, and input. New pages update automatically. Route changes and build-engine JS changes need a server restart.

Open image exports the displayed full page. The compact Panels and Transcript toolbar buttons open scrollable modals without taking space from the page. Panels offers each raw art SVG and its lettered export. Close or Escape restores focus to the opening button. These exports share the reader’s measured layout. Automatic wrapping uses actual font metrics; explicit word-count breaks retain accepted lettering. The local Art only control hides speech. Automated browser checks inspect measured layout without a reader control or diagnostic overlay. Face checks use contour bounds, not hair boxes, and do not replace visual review. Full-color pages have no CRT tint, scanlines, or shade overlay. Older typed HUD helpers remain internal renderer building blocks, not a demonstration or a second story authoring workflow.

Run cd web && npm run ci, then inspect the season library, each season’s episode list, and reader routes at desktop and narrow widths, including a project URL prefix. Check browser Back and the labelled return links at both levels. The reader displays one page. Buttons, contents, arrow/Page keys, wheel, and a single-finger vertical swipe change pages. Modified zoom gestures are not page turns. Open image uses the browser’s full-size image view and native zoom for the complete composition. Transcript exposes the selected page’s derived text in its modal; scrolling it does not turn the underlying page. Contents hidden from view are also hidden from keyboard navigation. Mobile image fitting is an overview, not a claim that small landscape lettering is readable without zoom or text.

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 pull requests and pushes to master, so the integrated tree is revalidated after merge: cargo fmt --check, cargo clippy --workspace --all-targets --features debug -- -D warnings, cargo test --workspace --features debug, then a windowed seven-example probe run --correctness-only smoke 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 curated release set lives in .github/workflows/probe-full.yaml, a five-shard suite with no push or PR trigger. release.yaml calls it and every platform build waits on it, so a tag cannot ship past a red selected example; run it by hand from any ref with workflow_dispatch. Both runtime lists are named, so scripts/check-probe-suites.py (a step in the check job) rejects unknown and duplicate names, empty sets, and smoke examples outside the full shards. It does not require every [[example]] to run in CI; the independent catalog drift test checks disk against Cargo.toml, and the default all-target check compiles the remaining examples.

    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_wfcGenerated hulls: a catalog plus a code-owned WfcPlan in, a ShipDesign out. Owns the plan types; every caller builds its own plan. No App, no systems - the editor’s Generate verb and the wfc_* examples call it.
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_commandThe command language and the NOVA COMMANDS terminal model: catalog, parser, prompt state. No bevy UI.
nova_interfaceThe TAB interface (Map, Ship and Inventory panes) and the : CRT command modal: pointer forwarding, casing, the back-out owner. A peer of the HUD, added by nova_core.
nova_consoleThe command shell’s dispatcher: the executor behind the cmd> prompt and the channel’s command lane. Sits above nova_menu because a setting command writes the resources the settings screen owns.
nova_scenarioScenario engine: events, filters, actions, variables, world, loader, objects.
nova_worldThe streamed open world: a cell generated from its coordinate alone by a SectorGenerator type, checked, and a 5x5x5 active window streamed around one live scenario. Names no content; the game gets it through nova_world_base. Examples with their own game plugins install their own generator.
nova_world_baseThe base game’s open world: the NovaLayeredWorld generator and its cluster policy over three environment fields, the OpenWorldSession seed New Game writes, and the plugin AppBuilder adds that streams the world only while an OpenWorld scenario runs with one player ship.
nova_eventsShared game-event kinds + entity identity components (gameplay <-> scenario).
nova_events_macrosThe EventKind derive behind nova_events’ engine events. Its only consumer is nova_events.
nova_assetsbevy_asset_loader setup; loads glb/textures/shaders/sounds; owns the mod merge, safe mode (a broken optional mod is disabled, not fatal, except during a scenario) + 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. New Game opens the world seed modal first.
nova_trainingThe training handbook’s data: the Lesson content type, catalog ordering, field notes, progress, and the pure lesson validation both content lint and the runtime merge run. A leaf under nova_modding, nova_assets and nova_menu.
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_interface, 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_channelDev tooling: the process channel. Named inputs, pointer moves, text, keys and command lines in on stdin; world snapshots and acknowledgements out on stdout.
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_benchDev tool: the agent bench - referees an external agent (a scripted baseline, pi, any process) playing a scenario over the process channel, audits and scores the run; the bench play/replay 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–
A unit, or what a field is measured incrates/nova_events/src/units.rs + crates/nova_events/src/scale.rsArchitecture
The HUD (widgets)crates/nova_hud/src/–
The TAB interface, its panes, and the : command modalcrates/nova_interface/src/–
The command catalog and parsercrates/nova_command/src/commands.rsArchitecture
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
What a streamed world cell holds, or when one comes and goescrates/nova_world/src/{generation.rs,streaming.rs}, base generator, clusters and environment fields in crates/nova_world_base/src/{layered.rs,clusters.rs,environment.rs}Architecture
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
A generated hull, or the plan it is drawn fromcrates/nova_wfc/ (plan.rs for the plan, WfcPlan::standard_hull for the shipped one) and each caller’s own plan builderConcept index, Ship layout sense
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 + interface, editor, menu, debug). The state machines:

  • GameStates { Loading, MainMenu, Playing } – top-level lifecycle. Leaving Playing returns through Loading: the content is re-read on the way to the front door, and the menu is built once, on the far side of it.
  • PauseStates { Unpaused, Paused } – the ESC overlay, nested in Playing.
  • GameAssetsStates { Boot, Loading, Processing, Loaded, Failed } – the asset pipeline that gates entry; on OnEnter(Loaded) the app hands off to MainMenu/Playing. Only the base game is MANDATORY: an optional mod loads beside the pipeline, and a broken one is switched off and reported instead of failing the boot. Failed is the mandatory failure, and it is terminal - the loading animation is replaced by a report.

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
Lore encyclopediaweb/src/lore/ -> /lore/readers + story authorsapproved world facts and published story references change
Story libraryweb/src/comics/ -> /story/readers + story authorsnavigation, TS page scripts, Python panel art, composition, or publication change; update the comic authoring README and keep drafts out of deployable builds
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? Apply the Changelog section of AGENTS.md; it owns the entry and release-baseline rules.
  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)
The world’s scale and the quantity types: METERS_PER_UNIT, LOAD_LIMIT, the Meters / MetersPerSecond / Meters3 seam, or any authored field moving between a quantity type and a bare f32 (nova_events/src/units.rs, nova_events/src/scale.rs, nova_ui/src/units.rs)every page that quotes a figure - glossary.md (the world unit is the build grid’s name and nothing else), combat-weapons.md, gravity-wells.md, sections/*Architecture (the boundary itself - state it there, reference it elsewhere), Ship sections internals, Scenario engine, both Extending guides, and /create/ wherever a field’s unit is statedweb/src/widgets.ts (each widget either converts or holds SI - it cannot do both), CHANGELOG (breaking - what a field is measured in IS its format)
Ship sections, integrity, typed damage, ammo (nova_ship/sections, nova_gameplay/integrity)ships.md, 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_*)ships.md, 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: the manual burn, the autopilot legs and the telemetry they publish, controller, camera (nova_ship/flight, nova_ship/input, nova_ship/camera, the maneuver instruments in nova_hud)flight-autopilot.md, keybinds.md, glossary.md (the Flight terms quote what each verb DOES), hud.mdArchitecture, /create/actions/ (StopShip and the other helm orders - a completion contract that moves lands there), /create/events/ (the maneuver-completion events)CHANGELOG
Targeting, radar, weapons, turrets, torpedoes (nova_ship targeting/sections, nova_hud)targeting-radar.md, combat-weapons.md, sections/turret.md, sections/torpedo-bay.md, sections/railgun.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
The streamed world: the cell generator, the active window, the streaming lifetime (nova_world)Architecture (crate map + graph - state there whether AppBuilder still leaves the plugin opt-in), Project tourCHANGELOG (Internals). No /create/ page: WorldConfig is code a caller writes, not authored content
Modding data format, bundles, catalog, local cache (nova_mod_format, nova_modding)/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?)
Generated hulls: the collapse, the tiles, and the WfcPlan that draws them (nova_wfc, nova_editor/generate.rs)ships.mdConcept index (the row under The ship), Ship layout senseCHANGELOG. The plan is CODE, not content - a plan field is not a format break, and there is no creator page for it
Mod portal + generator (scripts/gen-portal.py, nova_modding)/create/publish-a-mod/CHANGELOG
The TAB interface and the : command modal: the panes, the CRT emulator, the command catalog, and the dispatcher that runs it (nova_command, nova_interface, nova_console)interface.md, commands.md, keybinds.mdArchitecture (crate map, and the PauseStates named freeze)CHANGELOG; a new command MUST land in commands.md - that page is the exhaustive catalog, and help is generated from the same registry, so the two disagreeing is a bug
The process channel and its lanes (nova_channel)Architecture (crate map), and the command lane’s vocabulary is commands.md, not a second wire referenceCHANGELOG (a lane rename is breaking for every run script)
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
The training handbook: lessons as content, the practice ranges, the progress record, the field notes (nova_training, nova_menu/training.rs, nova_menu/training_store.rs, nova_authoring/base_content/lessons.rs, nova_authoring/base_content/scenarios/drills.rs)getting-started.md (the way in), and every page a lesson links to/create/lessons/ (the Lesson item), /create/scenarios/ (the roles and the practice-range contract), Architecture (the crate map and the graph), Concept indexCHANGELOG; RENAMING A WIKI PAGE OR RETITLING A HEADING BREAKS A LESSON’S LINK - crates/nova_authoring/tests/lesson_wiki_links.rs resolves every authored wiki_path against web/src and fails until the lesson is repointed
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
Comic/site deployment (deploy-comic.yaml, deploy-page.yaml, scripts/deploy-pages.py, webpack targets)Building and running, web/src/comics/README.md publishing procedurePreserve separate /story/ ownership, tag/commit provenance, and draft exclusion; keep the two publishers on one lock
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.
  • A quoted FIGURE is a claim about a UNIT as well as about a number. Change what a field is measured in and every page quoting it stays readable and becomes wrong, while a name-level grep finds the pages that mention the field rather than the ones that quote it. Re-derive the figure from the code; and check the static fallback prose inside a data-widget block, which no test renders.

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, creator, or lore page: add it to the matching section in web/src/docs-manifest.js, which drives publication, the sidebar, search, and related pages. Lore source and illustration conventions are in web/src/lore/README.md; it is a website section, not a second mdBook.
  • 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. New Game first opens the world setup modal (world_setup): a seed drawn from the game’s entropy, and only its Create inserts OpenWorldSession and hands off. The Settings modal (audio volume, graphics preset, UI theme, and the Controls tab that REBINDS every action in nova_input, one binding group at a time) is shared by both entry points. Persistence is NOT the menu’s: settings_store owns SettingsStorePlugin, which nova_core adds to every app, and it reads the same cross-platform store (RON file / localStorage), keybind overrides included. The two directions are split: every app READS, and only an app that builds the settings panel calls allow_settings_saves and earns the write direction. What the player has LEARNED is a second file beside it, training_store: opening a lesson reads it, and only a scenario Victory that a lesson’s proven_by names completes it. Same root, same access split, separate file - deleting settings.ron to fix your audio must not erase your progress.
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 bus-and-route sound engine every voice in the game goes through: bus for the four routes and the three volume tracks, mixing for the distance rolloff and the cue throttle, spatial for the stereo placement, voice for the one playback path), juice, shake, settings (MasterVolume/GraphicsQuality + apply systems; the per-bus InterfaceVolume/WorldVolume/MusicVolume live in audio/bus), 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_wfcGenerated hulls: a function from a section catalog and a code-owned WfcPlan to a ShipDesign, with no App and no systems in it. Owns the plan types too (WfcGrid, WfcVacuum, WfcKeel, WfcPart), because which sections one generator may reach for is generator policy and not mod content. TileSet::build reads the plan against the catalog and builds tiles only for parts nova_ship::cell_grid_fit accepts, each read at the 24 cube rotations through nova_ship::oriented_part, hull() collapses one seed, and every hull is put through the game’s own content lint before it is handed back. Each caller builds its own plan from WfcPlan::standard_hull and its own UI rows: nova_editor’s Generate verb, and the wfc_* examples.
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 unconditionally, headless runs included - the widgets register their keybindings in build, so a gate took those bindings out of the registry. The crate places NovaHudSystems between the section and camera sets itself.
nova_commandThe game command language and the NOVA COMMANDS terminal model, with no UI in it: the terminal (terminal: prompt, scrollback, completion), the matcher and typo suggestions (shell), and the curated catalog, parser and CommandChannel (commands).
nova_interfaceThe TAB interface and the : modal. pane opens the full-screen interface over the frozen world with a map pane (schematic local space, contacts, GOTO) and a ship pane (schematic player ship, repair, rebind, mates); viewer is the orbit camera both run on. terminal is the retained CRT modal: the casing and shader, the terminal nodes, the keyboard and pointer systems, the one back-out owner for both surfaces, and the flight-log model. 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_consoleThe command shell’s dispatcher: the executor behind the CRT’s cmd> prompt and the channel’s command lane. nova_command owns the LANGUAGE (catalog metadata, parser, CommandChannel) and stays a leaf; this crate owns the half that touches the world - inspection, the flight log, the persisted settings, and the armed cheats. It sits ABOVE nova_menu on purpose: a graphics or volume command writes the very resources the settings screen owns, and one of them has to be downstream.
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_worldThe streamed open world’s mechanism. It names no content except the identity a generated ship carries, CivilizationId (with its seeded name and per-aspect stream) and the four ShipRoleType roles, which it owns as the lowest crate a manifest and its generator share. AppBuilder does not add it directly: the game gets NovaWorldPlugin<NovaLayeredWorld> through nova_world_base. Examples that bring their own game plugins install their own generator - the playable world_sectors, world_features and world_clusters, and the system_world_sectors correctness range; the two field pictures (world_field_slices, world_field_clouds) sample nova_world_base‘s environment fields directly, and world_civilizations paints its civilization field; none of the three streams anything. Nor does the playable world_sector_inspector: it prepares one cell at a time with prepare_sector and spawns it with materialize_sector, with no WorldObserver, handing it a bodiless observer point two cell edges past the cell’s centre on every axis, so no ship is ever held wherever the free camera stands. An explicit WorldConfig<G> (there is no Default - a seed, a cell edge and a generator are named or nothing streams) names the SectorGenerator TYPE the plugin is generic over; one app installs exactly one. The generator fills a cell from its coordinate alone and returns a BODY-ONLY SectorManifest - rocks, planetoids and ships - and validate_manifest turns it into the private-field SectorDescription that preparation and materialization accept. The core enforces materialization contracts only: the requested cell, finite geometry, unique ids prefixed with the cell’s slug, shipped asteroid kinds, PlanetConfig::validate, a ship rotation that is a finite unit quaternion, a declared ship clearance that is positive and at most SECTOR_SHIP_CLEARANCE_MAX (400 m), an inline ship design with sections, unique section ids, prototype references only and finite section poses, every body’s clearance sphere wholly inside its cell, no two bodies overlapping, finite MetersPerSecond3 initial velocities for rocks and ships, and a crew present on exactly an intact ship whose patrol waypoints are all finite, one stop per waypoint, and each stop and the leash a finite positive number. The canonical description prints a ship’s rotation sign-normalized, so q and -q describe one sector, and prints its civilization, role, design integrity, design presentation and each section’s prototype patch through Debug, so a non-finite value never prints as None. It sets no body count limit; density belongs to the generator. Its shared primitives are stateless: WorldGeometry::require_owning_edge takes the generator’s own placement inset, and bodies_clear takes the generator’s own margin (the core check passes zero). Where each object stands, its inset, spacing, retries and ids belong to the generator. The uniform baseline lives in the examples’ world fixture with its own inset, margin and body count, beside the clustered generator, whose asteroid-rich, rock-only, planet-heavy, derelict-only and low-rock groups are decided on a global lattice from three environment fields and owned body by body by the cell each centre falls in, so a group crosses cell faces. It lays out its hulls through nova_world_base’s plan_ship from the ship parts of the loaded catalog. streaming runs the lifetime in six ordered NovaWorldSystems stages (Cleanup, Observe, Request, Collect, Materialize, Retire) around ONE live scenario, clearing every cell when the scenario or the config changes, preparing cells off the frame and spawning them through nova_scenario’s own object factories. Before a cell’s root spawns, every ship’s inline design is resolved strictly against the loaded GameSections, and any resolver error panics with SectorFault::InvalidShipDesign, because the spawn’s own resolver skips a broken section. Every mobile rock and ship spawns with its manifest velocity in meters per second, converted at the engine boundary. Every ship spawns with its manifest stock in its hold and its manifest credits as its balance, named <civilization> <role> or <civilization> derelict, former <role>. Inspection and the selected map contact show that name; a distant map blip shows only its minted contact code, and the map lists no contact before its code is minted. An intact ship spawns with an AI crew flying its civilization’s own allegiance (Enemy, Player or Neutral) with every system live: it flies the manifest’s waypoint loop, holding at each stop before the next leg, chases no farther than its leash, and an Enemy crew holds fire through its engage_delay grace. A derelict spawns unpiloted and Neutral, and also carries DerelictShipMarker, so its hull and docking ports stay live, every other section spawns inactive and detect_neutralized never neutralizes it, and LootableShipMarker, so a docked ship may Take its stock or its whole credit balance. A ship whose clearance sphere the WorldObserver’s body (its IntegrityEnvelope, or zero without one) overlaps is held as a PendingSectorShip child of the cell’s root and spawned unchanged by materialize_pending_ships once the observer is clear; retiring the cell takes it with the root. Every root is scenario-SCOPED, so unloading the session takes the world with it, and no streamed entity is scenario-ADDRESSABLE. Invalid content refuses with a SectorFault instead of materializing a partial cell.
nova_world_baseThe base game’s open world over nova_world: the complete concrete NovaLayeredWorld generator, the OpenWorldSession seed New Game writes, and the stable ids the world and its open_world bootstrap share. The generator reads three independent global environment fields (material density, volatiles, human activity) and decides, at each node of a 50 km lattice jittered by up to 10 km, one asteroid-rich, rock-only, planet-heavy or derelict-field cluster or none, each reaching at most 8 km from its anchor. A planet-heavy cluster stands two to four worlds at the corners of a pair, a triangle or a tetrahedron, their bodies kept CLEARANCE_MARGIN apart while their wells may overlap; a derelict field holds rocks around its hulls. A cell replays every node whose cluster can reach it and owns each body whose centre it holds, so a cluster crosses faces and every cell agrees about it. A cell resolves every cluster’s planetoids, then its rocks, then its hulls. A body whose clearance sphere crosses its cell’s face, or that comes within the 500 m CLEARANCE_MARGIN of a body placed before it in that cell, is skipped and counted; a planetoid that cannot be placed refuses the cell with SectorFault::Generation. A hull is placed only in a cell where its own cluster placed a rock or planetoid; otherwise the cell tries the escort rocks the cluster drew on the anchor side of that hull, places the first that fits and counts each one it tried, or skips the hull as having no companion. Escorts count toward each type’s derived extent, the halo and the gap between clusters, and a cell never looks past its faces for one. Overlap retries translate a placed escort and its hull together, keeping them 1,650-2,200 m apart; an impossible retry faults the sector. Every planetoid authors the mass that gives it a sphere of influence of 3.5 outer body radii under the default GravitySettings, and remains a static well. A cell that owns no cluster body may draw a background scatter of one to five rocks. The generator refuses an edge wider than 128 km or too narrow for its widest core of planetoids or a scatter, a world spacing or escort distance too short to clear the bodies it separates, bands that derive a cluster past its extent limit, and a planetoid reach whose surface pull passes the default surface gravity cap. sector_clusters and EnvironmentFields are public so debug examples read the same plan and fields; no streamed entity carries them. CivilizationField is the provisional civilization field that ship selection reads; the world_civilizations diagnostic reads it too. One civilization stands at each node of a 240 km lattice jittered by up to 40 km per axis. Its identity is the world seed plus the signed node, never its generated name. Its centroid, living or extinct status, advancement, role preference and allegiance (Enemy, Player or Neutral, one chance in three each, independent of the others) each come from their own seeded stream, so they do not depend on visit order. Influence is 1 at the centroid, 0.5 at 160 km and reaches zero only at 320 km, with no border between civilizations. The lattice bound keeps every place within reach of a centroid, and a place with none refuses with SectorFault::Generation. A place farther from the origin than a centroid gets up to four times its selection weight. Advancement rises with the centroid’s distance from the origin to saturation at 1,000 km, plus a seeded offset of up to 0.1. role_weights gives the four roles’ weights before content eligibility. ShipPartSnapshot is the provisional part catalog generated ships are built from. The open world builds one from LoadedSectionPacks when it arms and pins it in NovaLayeredWorld with the catalog digest, and every generated ship is laid out from it. plan_ship turns each hull a cell owns into a ship before the cell’s spatial checks: a primary civilization per cluster from those in reach of its anchor by selection weight, a 10% chance for each later hull to belong to another civilization in reach by influence, a role from the civilization’s role weights at the cluster’s environment among the roles its advancement can build (read through the provisional SHIP_ADVANCEMENT_CURVE, Linear), then generate_ship for a living civilization or generate_wreck for an extinct one. The planned ship carries the civilization’s id and the role to its manifest entry. An intact ship also gets a crew carrying its civilization’s allegiance; after the cell resolves bodies and relocates overlaps, plan_patrols gives each crew a loop of up to four seeded waypoints 1,500 m around the ship in its level plane, each kept only where the ship’s whole clearance fits the cell and clears every other placed body by CLEARANCE_MARGIN - fewer than two that fit leaves the ship holding its spawn point instead, counted in SectorClusters::routeless_ships. A derelict gets no crew. Every ship’s hold and credit balance come from ship_stock and ship_credits, each from its own stream keyed like the ship and scaled by the civilization’s advancement: a role-specific item mix toward a seeded share of what its hull sections hold at HULL_SECTION_CARGO_G each, a wreck’s toward roughly half that share of its remaining hull drawn apart from the intact ship’s, and a hold that cannot fit one unit of the role’s lightest item fails the cell; a wreck’s credits keep a seeded share of an intact balance at the same advancement, at least one credit. A retired cell comes back with its ships’ stock and credits whole; nothing saves looting or spending yet. The generator gives mobile bodies a seeded tangent only for a unique well whose full orbit fits the clear, unfaded band in its owning sector; otherwise it gives an unsafe single-well start an outward escape, free space an explicit zero coast, or fails when an overlap has no safe relocation. An intact ship stands level on its nearest planetoid’s outward direction, or on +Y with none, turned by the cluster’s seeded yaw; a wreck takes a seeded uniform 3D orientation. Overlap relocation preserves a ship’s rotation, even if its nearest well changes. Every draw is keyed by the world seed, the cluster node and the hull’s slot, so a ship is the same from every cell. A planned hull reserves SECTOR_SHIP_CLEARANCE_MAX in the cluster’s extent and halo; the cell’s checks use the ship’s own root-centred clearance. A layout or content fault fails the cell: a sector fault panics streaming, so a ship that no layout fits crashes the world where it stands. The static content lint and the world_ships debug example build their own from the same repo bundle packs. It takes the base and mod section packs in any order, sorts packs and ids, and allows a section overlay only along a dependency chain, where the most dependent pack wins. It refuses non-finite or non-positive part stats, a docking capture angle outside 0 to 90 degrees, a socketed weapon or thruster that fires between two axes, a socketed weapon, thruster, cargo intake or docking port with a socket on the face it fires, opens or docks through, and a missing hull, controller, thruster, cargo intake or docking port, and reports every fault. It excludes parts without link points, uncharged railguns and parts that fail nova_ship::cell_grid_fit for any other reason, then scores each family (hull health per volume, thrust, torque, weapon damage per second, intake aperture, docking capture distance in engine units, mining carve volume per second) and normalizes it log-linearly: the weakest usable part needs advancement 0, the strongest 1. eligible groups a role’s parts by source so each pack weighs the same; civilian and industrial ships get no weapon, and only an industrial ship gets a mining beam. content_hash is a versioned FNV-1a 64 of the sorted effective catalog. generate_ship is the provisional shape-first ship layout. world_ships poses every role at its lowest eligible and full advancement with each drive layout beside four standard-plan WFC hulls, flies each intact ship, stands one generate_wreck derelict per role with every active section disabled in place, no controller and Neutral allegiance, checks that commands, a canister and a hit leave it inert but damageable, and reports the snapshot hash, each ship’s mass, thrust per cell and hull and weapon parts, and the missing weathered section art. It strips the generated skin so hulls compare bare; --skin or C restores it, and the harness also shoots the low and high rows clad. Clad WFC hulls name no style and wear the undressed derivation. It builds one ShipDesign from a snapshot, a role, an advancement and a hull seed: a mirror-symmetric spine of structural cubes from a one-cell nose to a stern that holds a bank of one drive (a centre column with mirrored pairs, or pairs only), a cross-section that changes along the length, flight computers in centreline cells inside the spine, each mounted on the hull surface instead where no flight computer part fits its cell, the role’s weapons or cargo intake, and docking ports: a mirrored pair out of the hull’s sides where one fits, else one port alone. It wears the derived skin in its role’s base style (role_style_id): civilian civilian, industrial industrial, scavenger salvage, armored armoured. Those ids are nova_world_base constants the style builders share. The spine mixes up to three structural cube types in rings, decks or a shell. It prefers one source per hull, except for scavengers. Civilian and industrial ships carry no weapon; scavengers get 1 to 3 weapon slots and armored ships 1 to 5, rising with advancement. An eligible industrial request draws 0, 1 or 2 mining beams instead, the chance and the pair threshold both rising with advancement; a drawn count with no legal mount fails the request rather than flying with fewer. Drives and weapons draw parts that score within 0.5 below the ship’s advancement before weaker parts of the same source, and each weapon slot then prefers a part not yet mounted. An industrial intake and every docking port open to starboard or port with structural cubes across their backs. An industrial spine holds one flat body run, at least one cell wide, long and tall enough for its first ranked intake’s mirrored pair beside its first ranked docking port; a drawn body with no such run has its first body stations flattened, and the spine never shrinks below the length that holds the run. A layout whose drives give less than 0.06 thrust per cell of collider mass at density 1, or that has fewer than one controller per 50 cells, is rebuilt with a larger bank and more controllers. Section ids name planned slots. Every layout is checked for unique ids, eligible snapshot parts, the role’s families, mated contacts, symmetry but for a lone docking port, backed side intakes and docking ports, one socket graph (derive_link_point_graph), clear exit and docking lanes (blocked_exits), the thrust and controller floors, a clearance ceiling from 80 m at advancement 0 to 400 m at 1, and one off-centre outer structural cube outside every docking port’s back that a wreck could omit alone (Unruinable), so every generated ship can be ruined. After 8 failed seeded attempts it returns a ShipLayoutFailure with the seed, civilization, role and last constraint. generate_wreck ruins the same request’s intact design: it keeps the role, style, source, advancement, fittings and cell bounds, and omits seeded breaches of structural cubes, each grown inward from the outer hull, that keep one socket graph, clear lanes, the ceiling and the cubes across every docking port’s back. Each of 8 omission plans digs toward a fifth of the cubes (at least 3) off the mirror image, and the first to get there wins. Otherwise the plan with the most wins if it reaches the hull’s floor: as many mirror pairs of off-centre outer cubes as can each go alone, capped at that target and at what one deterministic pass in cell order omits together. Every generated hull has at least one. When all 8 plans fall short, that pass is the wreck; a pass that fails a wreck check fails the request, and nothing rerolls or skips it. No weathered or damaged part exists, so holes are its only ruin cue. NovaWorldBasePlugin, added by AppBuilder on the default game path after the scenario plugin, installs NovaWorldPlugin<NovaLayeredWorld> and one exclusive Update system ordered before NovaWorldSystems::Cleanup: it arms WorldConfig and puts WorldObserver on the player ship only while an OpenWorld scenario is live with exactly one player ship, and removes the config everywhere else. An armed world keeps its generator; two configs compare by catalog digest and snapshot hash only. A missing session, a second player ship, no LoadedSectionPacks or GameStyles, ship parts that do not build a snapshot, a role style that no loaded style authors (ShipPartFault::MissingRoleStyle), or a catalog digest that changes under an armed world panics, the last before any config is written. The same build and platform give the same pristine sectors from one seed in any visit order; nothing a player changes is kept, so a retired sector comes back as generated.
nova_eventsGame event kinds and entity identity components, shared between gameplay and scenario. Also the world’s scale: the Meters/MetersPerSecond/MetersPerSecondSquared/Meters3/MetersPerSecond3 quantity types and METERS_PER_UNIT (units), and LOAD_LIMIT (scale). See Units and scale.
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) and LoadedSectionPacks (merged sections and versioned ContentCatalogDigest of effective content and mod metadata, independent of map/merge order; rejects non-finite values and excludes raw resource bytes), safe mode (safe_mode.rs: the optional half of the installed set loads outside the boot gate, and a broken mod is disabled, persisted off and reported; the merge runs lint_section_config on every section of every enabled bundle before it publishes, so an error in the base bundle inserts FatalAssetFailure and ends in GameAssetsStates::Failed, and an error in a mod quarantines that mod and every enabled mod that depends on it, except in GameStates::Playing, where an invalid mod, a failed mod load, or a dependent of a disabled mod fails the run the same way), the portal client and downloads (portal/), and prefs persistence.

| nova_modding | Bundle/content/catalog ASSET LOADERS and the Content routing enum. See Mod files. | | nova_mod_format | Pure 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_training | The training handbook’s DATA, a leaf under everything that teaches: the Lesson content type and its catalog ordering, the field notes the menu and both loading screens draw, the progress record, and the pure validate both content lint and the runtime merge run. It knows nothing about drawing a lesson or loading one - nova_modding routes Content::Lesson, nova_assets merges the catalog, nova_menu draws it, and nova_authoring builds the base set. Its only game dependency is nova_gameplay, for the AssetRef<Image> a lesson’s media resolves through. | | nova_input | The 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_ui | Shared UI, a leaf crate everything that renders UI draws from: the UI THEME (theme::*: the authored UiThemeConfig format, the inheritance resolver, the two base themes the base mod ships as content, and the GameUiThemes/SelectedUiTheme/ActiveUiTheme registry every widget paints from), 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 interface 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_interface, nova_menu, nova_editor and nova_assets. | | nova_debug | Debug-only plugin (inspector, overlays). Compiled only under the debug feature. | | nova_info | Exposes APP_VERSION, injected by build.rs. | | nova_autopilot | Scripted 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_probe | Dev 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_channel | Dev tooling (not in the shipped game): the process channel a harness drives the running game through - named input holds, aim deltas, text, raw keys, pointer moves and command lines read as JSON lines on stdin, with world snapshots and per-line acknowledgements written back on stdout. It depends on nova_command for the command LANGUAGE, never on nova_console, so the wire cannot pull gameplay into a tool that only wanted to type. See Automation harness. | | nova_probe_cli | Dev 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_web | The 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_authoring | The 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_gen | Binary 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 --> iface["nova_interface"]
    core --> console["nova_console"]
    core --> scenario["nova_scenario"]
    core --> assets["nova_assets"]
    ship --> gameplay
    ship --> events["nova_events"]
    hud --> ship
    hud --> gameplay
    hud --> ui["nova_ui"]
    iface --> hud
    iface --> ship
    iface --> gameplay
    iface --> cmd["nova_command"]
    iface --> ui
    menu --> iface
    console --> menu
    console --> cmd
    console --> scenario
    menu --> ui
    editor --> ship
    editor --> ui
    editor --> wfc["nova_wfc"]
    wfc --> ship
    wfc --> scenario
    gameplay --> events
    gameplay --> ui
    ship --> input["nova_input"]
    hud --> input
    iface --> input
    menu --> input
    editor --> input
    scenario --> input
    scenario --> events
    scenario --> gameplay
    scenario --> ship
    scenario --> hud
    assets --> modding["nova_modding"]
    assets --> scenario
    assets --> training["nova_training"]
    modding --> modfmt["nova_mod_format"]
    modding --> training
    menu --> training
    training --> gameplay
    core --> worldbase["nova_world_base"]
    menu --> worldbase
    worldbase --> world["nova_world"]
    worldbase --> scenario
    worldbase --> ship
    world --> scenario
    world --> gameplay
    world --> assets
    world --> events
    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_interface, 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.

nova_world names no content, so its edges run downward only. The one game crate above it is nova_world_base, which names base content and is what nova_core adds; nova_authoring builds the bootstrap scenario and the derelict hulls from the ids nova_world_base owns. An example that brings its own game plugins gets no world unless it installs a NovaWorldPlugin itself.

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_interface - consumers of the ship and of gameplay, above both. nova_interface is above nova_hud in turn: it orders itself against NovaHudSystems.
  5. nova_core - wiring only.

Units and scale

Nova reasons in meters; the engine underneath does not. Authored metric quantities, including vector velocities, and player-facing readouts use SI; selected engine-side parameters such as gravity’s mu are exceptions below. Bevy transforms and meshes, avian colliders and velocities, shaders and the build grid all count in engine WORLD UNITS, and one world unit is METERS_PER_UNIT = 10 m - so a build-grid cell is one world unit and 10 m on a side.

The seam is the quantity types in crates/nova_events/src/units.rs - Meters, MetersPerSecond, MetersPerSecondSquared, the Meters3 displacement, and MetersPerSecond3 velocity, exported through that crate’s prelude. They carry no Deref to f32, so an SI quantity cannot reach an engine API by accident, and they cross in exactly two directions: to_engine() on the way out to Bevy, avian, a mesh or a shader, from_engine() on the way back. Every call site of either IS the boundary and says so locally. AsteroidConfig::initial_velocity stays in meters per second until its spawn path calls to_engine() for avian. Serialization is #[serde(transparent)], so a content file still reads blast_radius: 300.0 - the TYPE documents the unit, not a wrapper in the file. LOAD_LIMIT (crates/nova_events/src/scale.rs) is the other half of the world’s scale and is likewise SI, and nova_ui::units formats the player-facing strings without converting anything, so a readout cannot pick up a second factor of ten on its way to the screen.

Three kinds of number stay engine-side on purpose, and each says so where it is defined:

  • Geometry the engine owns - a collider half extent, a signed-field cell, a build-grid position, the answer structural_arm reads off posed colliders. These come out of avian or a mesh, so they are world units until something crosses them.
  • Quantities that are not a plain length. A gravitational parameter mu is L^3/T^2, so an SI one would be a THOUSAND times the world-unit one; DAMAGE_PER_UNIT_VOLUME is hit points per cubic world unit, an INVERSE volume. Neither is “times ten”, and a silent conversion of one is a worse defect than leaving it engine-side - so they stay engine-side and quote the metric figure a reader actually wants beside them.
  • Dimensionless ratios - a fire-gate cone, a closing-speed multiplier, an angular acceleration in rad/s^2 - which the scale does not touch at all.

The rest of this book quotes a world unit only where one of those three applies, and says “world unit” out loud when it does.

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 the render flag gates. 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, NovaInputPlugin (the bindings registry is a leaf: it holds the table every rig is built from, so it lands before any plugin that registers an action), GameAssetsPlugin, LoadingScreenPlugin, NovaGameplayPlugin, NovaShipPlugin (the ship orders its sets inside gameplay’s SpaceshipSystems brackets, so it comes after), NovaScenarioPlugin, then NovaHudPlugin and InterfacePlugin (HUD first: the interface orders itself against NovaHudSystems). Those two are NOT render-gated - a headless run assembles both. Each registers its bindings inside build, and under a gate a headless run kept only 15 of the 33 registry actions; everything GPU-side in them is already guarded by bevy (UiMaterialPlugin and friends no-op without a render sub-app). The price is that a headless measurement run carries HUD and interface CPU systems. Then NovaEditorPlugin, then SettingsStorePlugin (EVERY app, menu or not, and READING only: a settings panel is where a value is edited, not what makes it apply, so an example that never builds a menu still flies on the player’s own sensitivities, keybinds, volumes and quality preset - while an app with nowhere to edit one cannot write that player’s file back. from_env makes it inert under a scripted run, and the menu below is what grants the write direction), then NovaMenuPlugin (the editor and the menu 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), then NovaConsolePlugin (the Command shell’s dispatcher, unconditional and after the menu: a graphics or volume command writes the very resources the settings screen owns, and an example with no menu still has a CRT to answer status and ships on), 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. That handoff runs on every pass through Loaded, boot and content restart alike, so what separates them is the --scenario launch request: boot_into_the_game TAKES it, and a restart therefore finds nothing to open and lands in the menu. The status bar is taken down again on OnExit(Loaded) - its FPS icon is a handle out of the collection the restart is about to re-read, and insert_status_bar_item hangs every item off a Single root, so a second root would silence the new items rather than draw a second bar.

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.

Gravity is one-way: planet roots are static wells, while mobile asteroids and all ship roots are gravity-affected. Physical debris joins when its kinematic collision grace ends. PlanetConfig::mass is required; asteroids have no AsteroidConfig::mass. NovaLayeredWorld seeds a tangent only for a unique well whose whole orbit fits its unfaded, collision-clear band and its owning sector; inside an unsafe single-well band it seeds outward escape, and in free space it seeds an explicit zero-velocity coast. A candidate inside overlapping wells is repositioned deterministically within its cluster and owning cell, first in a checked unique-well orbit band, then in well-free space. If no candidate fits, SectorFault::Generation names the seed, sector and body; streaming still panics on that fault. Seeded tangents do not guarantee lasting orbits. Mobile children keep their spawn-sector root even across a cell face; retirement removes them and revisiting regenerates their initial position and velocity, resetting orbit phase and damage. Removing a well clears its DominantWell references. If an ORBIT target disappears before the maneuver reaches Hold, the autopilot disengages and the order fails; a completed held ORBIT retires its execution and helm authority when its well disappears. A later engage starts with fresh autopilot phase and plan state.

bevy_rand is added THERE and nowhere else, so the whole app holds exactly one seeded GlobalRng (NOVA_SEED picks the seed, see Environment variables). A gameplay system that needs randomness takes it as Option<Single<&mut WyRand, With<GlobalRng>>> and builds no fallback of its own: a second entropy plugin anywhere in the assembly does not make two RNGs, it makes every one of those systems find none. Each of them logs the broken contract once and then does its non-random work anyway - a destroyed section still loses its collider, it just bursts no debris. nova_gameplay’s plugin test pins the count at one.

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.
  • Leaving Playing goes back through Loading, not straight to the menu. nova_menu writes ReloadContent on OnExit(Playing) - the Wesnoth rule: the front door is where the game catches up with what is on disk, so a scenario just played, a ship just saved or a mod just installed is merged before the Scenarios picker, the campaign list or the ship catalog is read again. restart_through_loading rewrites the pending MainMenu into Loading in the StateTransition schedule, before StateTransitionSystems::DependentTransitions, so the menu, its UI camera and its ambience backdrop are never built for the one frame the restart would have thrown them away in. Every exit path gets this - the pause overlay’s Back, the editor’s File menu, the outcome frame, a scenario’s own ExitToMenu - because the redirect is on the transition rather than on the four call sites, and OnExit(Playing) is also where the scenario is torn down (end_gameplay_scenario) rather than on whichever screen comes next. Menu ENTRY owes the same debt on its own account: load_menu_ambience triggers UnloadScenario before it draws anything, so the branch that finds no clean backdrop - a mod set with nothing flagged, or nothing the content gate lets through - puts its bare camera over an empty world instead of over whatever was last running. An app with no content pipeline (no GameAssets) is exempt: it has no restart to come back from.
  • PauseStates { Unpaused, Paused, Interface, Commands } - the freeze axis. Paused is the ESC pause overlay; Interface is the TAB pane (Map, Ship or Inventory, InterfacePaneType, a resource that outlives the state); Commands is the NOVA COMMANDS CRT modal (cursor freed, no pause menu). The live transitions are Unpaused <-> Paused, Unpaused <-> Interface, and Commands to and from each of Unpaused, Paused and Interface: the modal is a surface OVER what was there, so NovaOsCloseTransition::return_to records the state it covered and closing restores it. nova_gameplay owns the enum and gates the spaceship sets; nova_menu owns the toggles and the pause overlay UI. The two keys are gated differently. Tab needs a player ship and so runs in_state(Playing); Tab and M both refuse while a ship section rebind waits for its key, so the capture is not dropped. : is a GLOBAL gesture over the main menu, the editor and flight alike - gated only on no armed rebind (a settings chip or a ship section waiting for its key) and not mid-load, where there is nothing yet to inspect. Escape has ONE owner for both surfaces (close_surface_from_menu_keys): an armed rebind keeps the key, then the modal closes, then the interface closes, then the pause menu may act, so one press never closes two layers. On the main menu the open shell is still the active modal, because its full-screen backdrop takes every pick and the menu buttons under it are unreachable until it closes.
  • The pause PANEL is reconciled, not spawned on the way in (reconcile_pause_overlay): Paused is a freeze axis three surfaces share, and the outcome frame and the FAILED TO START report each hold it while drawing a modal of their own. The panel exists exactly while Paused is the pause MENU’s, so an outcome landing behind it takes the screen, and an outcome clearing into a pause the player still owns hands it back. Exactly one modal, whichever arrived last.
  • Losing the window pauses interactive play (pause_on_focus_loss): an unpaused run over a live scenario takes the ordinary pause the moment the window is not focused, and regaining focus never resumes - Resume is the player’s. A run already paused stays as it is, so an open interface, an open command modal or a shown outcome remains the active modal, and the editor’s build mode (no live scenario) never pauses at all. FocusPause carries the policy and boots OFF under a harness (harness_env_active): a probe drives an X display nobody is looking at, and a --norender run has no window to lose. An authored auto_advance_secs keeps running on the wall clock while unfocused, and the chapter it advances into gets no unpaused frame - the outcome’s pause is TRANSFERRED to the pause menu rather than released (examples/systems/bug_outcome_pause.rs).
  • The freeze itself is a NAMED hold, not a boolean: ClockFreeze counts FreezeOwner::{PauseMenu, Interface, ScenarioLoad} and stops Time<Virtual> + Time<Physics> while any owner holds. It has to be named because the surfaces stack - : opens the modal over an already-paused game - and the handover is a state transition: OnExit(Paused) releases the PauseMenu hold before OnEnter(Commands) takes the Interface one, and the way back re-takes it. Naming the owners is what stops one surface’s release from unfreezing the other’s world. Interface and Commands share the one Interface owner, and release_clocks_for_interface skips the release when the transition enters the other of the two, so opening the modal over a pane and closing it again never ticks the world between a release and the re-hold it would need. The hold is SCOPED to Playing (hold_clocks_for_interface): what runs behind the main menu is the ambience backdrop, a cinematic the modal is drawn over rather than a game the player is being kept out of, and stopping it would leave the front door on a still frame for as long as the modal is up (examples/systems/system_command_shell.rs).
  • Every full-screen surface takes its GlobalZIndex from one table, nova_ui::layer: HUD, the menu’s own panels, the editor’s ceiling, the report frames (outcome, FAILED TO START, MODS DISABLED), the pause menu, its Settings panel, the command modal’s backdrop and monitor, the diagnostics exempt from its dim, the scenario loading screen, the fatal asset report. The TAB interface root sits on the menu-panel layer: above the flight HUD, below the modal. Two modals sharing a number are ordered by whatever the UI stack’s traversal produces that frame, which is not a decision - the pause Settings panel and the open command modal shared 11 until the table was written, and only survived it because the pause overlay is DespawnOnExit(Paused) and is gone before the modal draws. The editor keeps its own ladder (nova_editor::ui::layer) for chrome the two tables cannot see across; that ladder lives entirely under EDITOR_CEILING_Z, pinned by a test, so a pause or an outcome covers editor chrome instead of losing to its foot bar.
  • The window a native run opens is BORDERLESS by default and has a floor. WindowModeSetting defaults to Borderless, and the store’s field carries #[serde(default)], so a fresh install and an older store with no field both open full-screen while a player who explicitly saved Windowed keeps it; apply_window_mode is registered only for a READING store, so a harnessed run (inert store) never touches the window it was given. window_plugin hands the window manager MIN_WINDOW_WIDTH x MIN_WINDOW_HEIGHT (640x600) as resize constraints. The floor is not what makes the layouts safe: the Settings panel (the widest modal, and the same one from the menu and from pause) sizes itself at SETTINGS_PANEL_WIDTH_PCT of the window capped at SETTINGS_PANEL_MAX_W, because a canvas in a narrow page column obeys no native constraint. A fixed width there did not overflow - the panel is a flex ITEM, so at 500 px the 620 px panel measured 500, squeezed edge to edge with the keybind rows’ label column paying for it (examples/systems/system_pause_settings.rs).
  • GameAssetsStates { Boot, Loading, Processing, Loaded, Failed } (nova_assets) - asset pipeline. Boot loads the UI font the loading screen itself draws with; Loading loads the rest of the MANDATORY set (the shared art and the catalog, whose only loaded dependency is the base game’s bundle); Processing kicks the OPTIONAL cataloged mods, waits for them to settle, and merges. Scenario setup hooks OnEnter(GameAssetsStates::Loaded) - see examples/systems/system_scenario_grammar.rs. Failed is the MANDATORY failure and is terminal: nova_core’s loading screen replaces its animation with a report that offers Quit on native and browser instructions on the web, and draws in the default font when the boot font is what failed. An OPTIONAL mod never reaches it - safe_mode disables that mod, persists the choice, and the front door owes one MODS DISABLED report per recovery episode (examples/systems/bug_failed_assets.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 Create / Sandbox
        Playing --> Loading: leaving gameplay (content restart)
        state "Playing" as Playing {
            [*] --> Unpaused
            Unpaused --> Paused: ESC / focus lost / outcome
            Paused --> Unpaused: ESC
            Unpaused --> Interface: Tab
            Interface --> Unpaused: Tab / ESC
            Unpaused --> Commands: :
            Commands --> Unpaused: ESC
            Paused --> Commands: :
            Commands --> Paused: ESC
            Interface --> Commands: :
            Commands --> Interface: ESC
        }
    }

    state "GameAssetsStates" as AS {
        [*] --> Boot
        Boot --> AsLoading: Loading
        AsLoading --> Processing
        Processing --> Loaded
        Boot --> Failed: mandatory asset failure
        AsLoading --> Failed: mandatory asset failure
    }

    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 pose – Transform, 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. The same method caps the compute task pool at nova_core::MAX_COMPUTE_WORKERS threads, so the parallelism a par_iter or a PostUpdate system can reach is bounded too (why).

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 training lessons ARE data: the Rust builders under crates/nova_authoring/src/base_content/ (sections/, ships/, styles.rs, scenarios/, lessons.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.
  • The command modal and the TAB interface: the terminal model - crates/nova_command/src/terminal/state.rs; the command catalog, parser and CommandChannel - crates/nova_command/src/commands.rs; the dispatcher that runs them against the world - crates/nova_console/src/dispatch.rs; the : gesture - open_command_shell in crates/nova_menu/src/pause.rs; the interface’s open, close and pane switch - crates/nova_interface/src/pane.rs; the shared back-out owner - crates/nova_interface/src/terminal/input.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.
  • The training handbook: the lesson format and catalog ordering - crates/nova_training/src/catalog.rs; the validation both content lint and the merge run - crates/nova_training/src/validate.rs; the screen - crates/nova_menu/src/training.rs; the progress record and what a won scenario proves - crates/nova_menu/src/training_store.rs; the base lessons, their practice ranges and what each one is proven_by - crates/nova_authoring/src/base_content/lessons.rs and .../scenarios/drills.rs. A lesson’s wiki_path points into the web manual under web/src/, and crates/nova_authoring/tests/lesson_wiki_links.rs fails if a page or heading it names stops existing.
  • 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 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). Attitude hardware only - what a ship is PERMITTED to do is ShipCapabilities on its root. A ship needs one to be steerable; 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 ammunition: Limited(n) drawing PdcRound on reload.
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 ammunition: Limited(n) drawing Torpedo on reload. The TYPE is the run-in - how fast and how evasively; everything else on the config is the tube.
RailgunSpinal lance. No traverse: the HULL aims it down muzzle_offset. Tapping the trigger commits, the bolt walks the bore for charge_seconds, and the shot leaves whether or not the nose is still on the target. The slug deals slug_damage to every layer it rakes; slug_power and not a layer count bounds it, optional rake_radius spends that budget on a wider corridor instead of unused depth, slug_speed x slug_lifetime is the reach, and recoil_impulse lands at the muzzle point so an off-axis mount yaws the ship. Usually ammunition: Limited(1) with a long reload drawing RailSlug.
DockingDocking port. A cylindrical, rotationally symmetric collar that holds this hull to another one. capture_distance, capture_angle and the two relative-speed ceilings are the envelope a DOCK is graded against; the sleeve that reaches across once the dock holds is an animation track, not a collider (see below).
CargoIntakeCargo intake. Takes a CargoCanister whole into the ship’s ShipInventory and drops the Inventory pane’s jettisons from a CargoIntakeEjectionQueue, front first, one at a time while the door is open and the birth point is clear. Each live intake owns a detached static Avian Sensor trigger, aperture_width by aperture_height by capture_gap deep, in front of the Cuboid collider’s -Z face: not a child collider, since the workspace has no collision layers and a sensor in the ship’s compound would join it for mass, bullets, locks and overlap reads. detection_range opens the door (visual only for a take); a canister whose collider touches the trigger is taken whole, at any speed, if the hold has room; a canister in two triggers goes to one intake once; eject_speed sets a drop; the IntakeDoor track gates only the drop. The door, drop and take trigger events that ship_audio/machinery.rs voices (cargo_intake_section.rs).
MiningMining emitter. Each emitter deploys on its own key: a player ship’s input_mapping entry for the section becomes a SpaceshipMiningInputBinding rig that sets that section’s MiningSectionHeld (input/player/weapons.rs), and lint and spawn refuse a player mining section with no entry or an empty one, and two mining sections that share a source. While it is held, drive_mining_emitters opens the StowDoors track, then extends the StowLift tip; on release the tip goes in before the doors shut (mining_section.rs). Each change of the doors’ target triggers MiningDoorsMoved, and ship_audio plays the authored door_open_sound or door_close_sound at the section; the spawn snap and a held key play nothing. Each fully deployed emitter pulses on its own clock every pulse_interval_seconds. A pulse casts a ray from its collider’s -Z face against the travel-locked rock alone, within reach, and carves carve_radius_cells at the hit. Ore is paid per newly emptied corner after the remesh, then leaves the rock as canisters one at a time. A passed pulse plays the authored pulse_sound at the hit and, in a rendered app, flares the additive beam shader (assets/shaders/mining_beam.wgsl) and throws a fixed spark burst. A refusal is a MiningPulse with a MiningRefusalType, plays nothing and changes nothing (nova_scenario/src/mining.rs).

run_cargo_intakes publishes CargoPickupReadiness for each live intake/canister pair on the fixed clock, including out-of-range canisters. A pair names only the ship, intake, canister and ready; it carries no pose. The HUD draws its live pickup sight for one published pair from rendered GlobalTransform poses, with the face from cargo_intake_face, the helper the fixed pass also uses. The canister must be within CARGO_PICKUP_SIGHT_RANGE (200 m) of the player’s ship, with no lock needed: a travel-locked canister first, else the nearest intake face, then the lower entity IDs (pickup_sight.rs). No pair in range draws no sight. A take despawns the canister in the same pass, so the sight clears with it and CargoCanisterTaken voices the take.

GameSections(Vec<SectionConfig>) is the resource of section blueprints. Every base prototype is GENERIC and authored under crates/nova_authoring/src/base_content/sections/, one module per family (hull, controller, thruster, turret, torpedo_bay, railgun, docking_port, cargo_intake, mining_beam); a mod that brings modelled craft declares their parts itself. The 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. structural_arm measures it off posed colliders, so its answer is in engine world units and the caller crosses it with Meters::from_engine before AttitudeEnvelope divides by it - which is why METERS_PER_UNIT appears in the formula above and not in the code. The Ledger corvette reads 2.76 u, i.e. 27.6 m. BodyRadius is a scenario-obstacle radius and is not it.
  • LOAD_LIMIT is crates/nova_events/src/scale.rs and METERS_PER_UNIT is crates/nova_events/src/units.rs - one definition each, shared by the physics and by the player-facing formatter in nova_ui::units. Both halves of the world’s scale, and the boundary they sit on.
  • The ceiling comes out in rad/s^2 either way: LOAD_LIMIT / r is (m/s^2) / m, so the scale cancels and no figure on this page moves with it.

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.

That returned ceiling is a MAGNITUDE, and the loop that spends it is what keeps it pointed the right way. AttitudeEnvelope::available is paired with sustained_turn_rate on the same PDController (sustained_angular_speed): past that rate the PD drops whatever part of its requested acceleration lies along the spin and keeps the rest, so the authority can brake and steer but not tighten. Without the pairing a held turn spends the returned ceiling on going faster and settles wherever the controller gains run to - which is what the load limit exists to stop, and what examples/systems/system_turn_limit.rs flies.

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 pass measures or it does nothing. A root with no finite, positive inertia and arm yet - the spawn frame before avian has computed mass properties, or a hull that has lost every live section and is no longer sized at all (see publish_hull_radii) - is SKIPPED, and every controller on it keeps the tuning it already had. Dividing by a missing arm writes an infinite structural ceiling onto each PDController, which disables the load clamp for that tick.

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.

The spinal lance

Railgun is the one weapon kind whose aim is the HULL. It fires down the section’s own -Z, has no joints and no cone, and that is why it is its own SectionKind arm rather than a turret with a narrow arc: the turret code is about cones and joints, and a lance has neither.

The whole cycle is one system, charge_and_fire_railgun (sections/railgun_section/firing.rs), on the FIXED clock:

  • The commit. A trigger pull moves RailgunCharge::Ready to Charging. The MAGAZINE gates the commit rather than the shot - committing an empty gun would burn a charge for nothing and hide the reload from the player - and the shell is spent only once the shot is certain. The section reads RailgunSectionInput and never clears it, so a held trigger is a gun cycling at its own cadence rather than a second mechanic.
  • The safety is LIVE through the charge. WeaponsHot gates the commit and is re-read every tick: a gun safed mid-charge dumps the charge, snaps the Charge cue back to 0 and KEEPS its shell rather than firing it into a friendly. That is the only way out of a commit; the trigger has none. A ship carrying no WeaponsHot (a bare headless rig) fires freely.
  • The charge. charge_seconds runs off Time, and the Charge animation cue is SNAPPED to the gameplay fraction every tick rather than played. Both travel times on the shipped charge_bolt track are zero for that reason: an authored travel time would be a second clock, and the bolt would reach the brake before or after the shot it announces.
  • The shot and its recoil, in the same tick. They have to share one, or an impulse applied a tick late pushes a ship that has already moved on from the pose it fired in. That is what keeps the cycle in a single system and earns its too_many_arguments expect. The bore is read off the RAW physics pose through local_pose_in_root, not GlobalTransform, which inside FixedUpdate still holds the previous frame’s eased render pose. The slug inherits the muzzle’s full rigid-body velocity, swing included.

The slug is an ordinary nova_gameplay::rounds Pierce round with layers: u32::MAX, so slug_power alone bounds the depth: it curves in wells, is charged once per layer through pierce_remainder, and needs no second damage pipeline. slug_damage is dealt FLAT to every layer (hit_bite does not scale Pierce), while each crossing costs that layer’s MAX health divided by pierce_power_multiplier - and a slug at 15,000 m/s always sits at that curve’s 3.0 ceiling, so the shipped 1800 power is 27 reinforced hull blocks.

Nothing that flies is 27 blocks deep, so the optional rake_radius spends the surplus on WIDTH: a sphere of that radius trails the tip, tangent to it, and everything it sweeps over a body the tip DIRECTLY hit takes the same flat bite out of the same budget. RoundRake carries the arming, per body and for the slug’s life; sweep_raking resolves the tip, the trailing capsule and the charge order in three passes. Omitted, no RoundRake is inserted at all and the slug takes the untouched narrow path. The base lance authors 10 m, measured against 40 m in system_railgun_lance’s stand bank: both spend the same budget, and the radius chooses whether it goes through the hull or across its face.

One fixed step of a raked slug, as sweep_raking resolves it:

flowchart TD
    T[Pass one: sweep the narrow tip] -->|direct hit on a body| A[Arm that body for the slug's life]
    T -->|passes near a body| M[Nothing - a near miss is a miss]
    A --> S[Pass two: the sphere trailing the tip by its radius]
    S -->|each collider of each armed body| C{Inside the swept capsule?}
    C -->|beside the track| K[Contact at its lateral offset]
    C -->|ahead of the tip| N[Untouched until the tip gets there]
    C -->|already charged| N
    K --> O[Pass three: order by depth, then axis outward, then entity]
    T -->|the tip's own contacts| O
    O --> P[Charge each once through pierce_remainder]
    P -->|power left| S
    P -->|budget empty| E[Slug spent]

The capsule test is analytic - the corridor measurement from corridor_contact against the sphere’s track - rather than a spatial query, because parry’s intersection_test returns false negatives for shallow overlaps against a capsule this long - a slug at 15,000 m/s sweeps 23 world units of the capsule parry is being asked about per 64 Hz step, which is 234 m - and the first version of the rake, which queried, cut a corridor with its corners missing. The body’s own colliders are the candidate set, so there is no query cap under the power budget either.

Recoil goes through apply_linear_impulse_at_point AT muzzle_offset, never at the centre of mass, so an off-axis lance yaws the hull every time it fires. every_lance_puts_its_muzzle_on_the_brake_face pins the offset to the face for exactly that reason: it is one number that is silently wrong in two ways at once - the slug spawns inside the gun, and the torque is off by the same distance. every_lance_holds_exactly_one_shell pins the other half of the design: capacity 1, a reload of 1, and a reload delay longer than the charge, so the reload is the tempo.

The lance’s sockets (lance_link_points, nova_authoring) leave the muzzle face bare. A lance cannot traverse off its own line, so a socket there is an invitation to bolt a plate into the bore; no_lance_sockets_the_face_it_fires_through holds it.

The pilot-facing half is nova_hud’s bore sight (crates/nova_hud/src/bore_sight.rs), the only instrument that says where a hull points. It traces the bore and walks the crossed sections through the SAME pierce_remainder, ringing each section the shot would destroy - a sight with its own copy of the pierce math is a sight that eventually lies. It is gated on WeaponsHot and drawn dimmed rather than hidden on an empty magazine.

The AI half is deliberately crude and recorded as such (crates/nova_ship/src/input/ai/railgun.rs): a raider commits when its orbit happens to sweep the bore across a target it is already fighting, inside about eight degrees and 60% of the slug’s reach, with a per-gun cadence over the gun’s own reload. It does not FLY the shot.

Docking ports, and what holds a pair together

A dock is deliberately NOT an attachment. Two docked hulls stay two rigid bodies held by one avian FixedJoint, and every part of the mechanic follows from that: no authority moves, no mass is merged, and either ship can leave.

The code is crates/nova_ship/src/sections/docking_section/: mod.rs is the config and the sleeve’s state machine, port.rs the candidate search, and connection.rs the connection’s whole life.

Finding a pair (DockingPorts, a SystemParam). DOCK names two SHIPS - the presser and the ship it has locked - and the game picks the ports. A port is eligible when it carries no DockedPort and is not SectionInactiveMarker. Each pair is then graded on the STRICTEST of the two ports’ envelopes, so a hull cannot loosen another’s rules by offering a lax port:

  • the face gap between the two RETRACTED outer faces, not between section origins. A port’s face is (0, 0, -0.5) in its own frame, because a section’s outward axis is local -Z everywhere in Nova.
  • the opposition of the two outward axes, within capture_angle. Roll about the docking axis is not read at all: the ports are cylinders, so a twist is not a misalignment.
  • the relative linear and angular speed of the two BODIES. Absolute speed is irrelevant; two hulls holding formation at 2 km/s are stationary to each other.

best_candidate ranks survivors by (gap, opposition, first_id, second_id). The two EntityIds are the tie-break, so two identical pairs resolve the same way on every machine and in every replay.

Making the joint. DockingConnectionRequest re-runs the search - the envelope is re-read at execution, never trusted from the frame the offer was drawn in - and spawns ONE connection entity carrying both the DockingConnection record and the FixedJoint. The joint is built with explicit LOCAL frames, taken from the two body poses (p1, r1), (p2, r2) and the midpoint m of the two faces:

#![allow(unused)]
fn main() {
FixedJoint::new(first_ship, second_ship)
    .with_local_anchor1(r1.inverse() * (m - p1))
    .with_local_anchor2(r2.inverse() * (m - p2))
    .with_local_basis2(r2.inverse() * r1)
}

Both anchors land on m, and the second body’s basis holds the relative rotation the two hulls met at, roll included. Do not use the global with_anchor / with_basis: avian3d 0.7 converts a global basis as basis * rot.inverse() where its solver needs rot.inverse() * basis, so the joint holds the capture pose only when the two hull rotations commute. A belly-up hull against a yawed partner does not, and the joint swings the partner into the hull. A bare FixedJoint::new(a, b) carries JointFrame::IDENTITY and would yank the two hulls into the same origin.

Nothing writes a velocity anywhere on this path. The constraint is the physics’ job, so a pair that met while drifting keeps drifting; a hull that had been zeroed on capture would be a hull the solver has to fight.

The sleeves. DockingSectionState runs Retracted -> Extending -> Extended off the DockTube animation cue, and back down on release. It is driven only AFTER the joint exists, so the tubes can never be what holds the ships together. The track is a plain Translate on dock_tube* nodes: art only, with no collider growth, which is why two mated sleeves may overlap without the hulls touching.

One hull drives the pair. DockingConnection.helm is DockedHelmType: Neutral (the dock’s start, and the state after a lost holder) or Held(player), toggled by DockingHelmRequest from the dock_helm binding (H). A request to take the helm of a pair with measurement_fault is refused; handing it back is not. The driver is the holder while Held, else the non-player endpoint, else second_ship. measure_docked_assemblies (DockingSystems::Assembly) writes DockedShip.drives on both roots and a DockedAssembly (combined mass, world centre of mass, velocity, root-frame inertia, reach) measured every tick. An unmeasurable pair logs an error once, drives neither root and sets DockingConnection.measurement_fault; the next measured pass clears it, and a new connection starts clear.

DockedShip.drives is the single gate, read at the FORCE and at every intent writer, so pilot, AI and scripted order are all held by the same line on the root that is not driving: thruster_impulse_system, the manual balancer, the RCS writer, the autopilot, the scripted orders (paused, never judged) and the AI brains all skip it. The HUD mode chip and the blocked dock chips read it too, so a pair that is not driven never shows a live maneuver. The mode chip reads measurement_fault first and shows HELM FAULT, because drives alone cannot tell a fault from a hull off the helm. The HELM chip reads it through FlightVerbHints::helm_fault, and no verb is helm_blocked while it is set, because HELM cannot take the helm. The interface’s Map GOTO refuses on it before it asks for the helm. On the driver, the autopilot, the flight authority, the manual balancer and the RCS budget read the assembly, never the root alone. An AI driver does not patrol, orbit or fight while docked: update_passive_flight holds the pair on a Stop maneuver instead, and update_ai_target clears its target, until the undock hands its route back where it left off. A driver with no DockedAssembly moves nothing: thruster_impulse_system skips it, and the autopilot, scripted align, manual balancer and RCS writer each log an error once and skip it. Scripted align aims from the assembly’s centre of mass. apply_docked_helm_wrench replaces the attitude torque with one wrench split over both roots: a = I_asm^-1 tau, each root gets I_own a and the force m (a x r) about the assembly centre of mass. RCS applies m_i * dv at each root’s own centre of mass, so a translation adds no torque. The undocked path is unchanged.

park_suppressed_docked_helms re-parks the attitude command of each root that is not driving on the rotation it actually has, every tick, so a hull that takes over or is released holds the direction it is already pointing.

Ending it. Three paths, and each removes only the connection it owns:

  • the verb (on_docking_release_request): DOCK pressed again, from EITHER hull, exactly as ORBIT pressed again disengages. This is the only pilot-facing path, and it is ungated - a capability withdrawn while docked must never strand a hull clamped to something.
  • a destroyed port (on_docking_port_removed_release), which frees the surviving port.
  • a broken endpoint (release_broken_docking_connections), for a ship that is gone entirely.

nova_ship/src/sections/docking_section/tests.rs grades the search; the system_docking_ports range grades what the SOLVER does with the joint.

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). These are BUILD-GRID dimensions and stay in engine world units for that reason: a collider is what avian receives, and one unit is one cell, 10 m on a side. 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, a capabilities set (what this spawn is permitted to do), a required inventory stock, a required lootable flag and a required credits balance. Stock must fit the resolved hull’s hold at 100 kg per hull section; lint rejects heavier stock and load fails if it escapes lint. lootable: true permits Take while docked even when the ship was not neutralized; credits funds Buy and Sell against real stock. A design is a ShipDesignSource, either an Inline ShipDesign or a Prototype naming a catalog design by id with this spawn’s own section_patches over it. The design carries the integrity (the optional collapse_threshold, below), the presentation (the derived cladding, the style and the cockpit voice) and a list of SpaceshipSectionConfig, each placing one section at a position + rotation relative to the ship root. That position is the one authored vector that is not a distance: it is a BUILD-GRID CELL, so it stays a bare Vec3 of engine world units and sections stack by whole cells of 10 m. It comes with a source (Inline, or Prototype with its own SectionConfigPatch). The player config carries the input mapping (section id -> key/gamepad bindings) and nothing else, and refuses an unknown key rather than dropping it; the AI config carries patrol/patrol_stops/orbit/leash/engage_delay. patrol_stops names, one per patrol waypoint, the seconds the AI holds station there before the next leg; patrol_stops_fault is the shared check lint and the spawn both run against a mismatched length or a negative or non-finite stop.

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 block ship builders under crates/nova_authoring/src/base_content/ships/ for complete generated examples, and webmods/the-ledger/ledger_ships.content.ron for hand-authored ships built out of modelled parts a mod brings with it. The editor (crates/nova_editor) assembles ships interactively using preview_section, which has no health or rigid body and never enters the damage pipeline. A part placed from the gallery is a Prototype reference from the moment it lands (spawn_section_node), and stays one when it is tuned: inspect::edit_section applies the current patch, hands the edit a whole config, then asks SectionConfigPatch::between for the delta back and keeps the Prototype source with that patch. between is the checked inverse of apply

  • it re-applies its own result and compares - and returns None when the edit is outside what a patch can say (a different kind, a muzzle the prototype does not carry), which is the only case that falls back to an Inline copy. So the editor never keeps a second list of which fields the patch reaches.

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; shed_dead_fixtures takes a spent plate away and nothing puts it back.
  • A spent plate LEAVES rather than vanishing, the same finale a destroyed section gets. shed_dead_fixtures (sections/fixture.rs) drops its ChildOf where it stands, promotes its world pose to a local one, and gives it a kick outward from the hull, a tumble and a TempEntity timer - inheriting the ship’s motion through inherited_motion, so cladding does not hang in space while the hull flies out from under it. It is the SAME entity, so the meshes drawing it and the greebles standing on it come along untouched. Its colliders do not: shed cladding is DEBRIS - kinematic and untouchable - because a hull wears hundreds of plates and a dynamic body per plate is the cost a dying section already refuses.
  • The shed is CAPPED and runs on the FIXED STEP, in the phase the deaths arrive in. SHED_TICK_CAP bounds it to 24 fixtures a TICK and ShedBudget bounds it to 48 a FRAME; the rest are deferred, never dropped, because shedding is what removes the ChildOf a plate is matched by, so a deferred one matches again next time. Two caps and not one, because a frame recovering from a long stall banks up to 16 fixed steps (Time<Virtual>’s 0.25 s max_delta) and pays for all of them at once - and the frames a hull loses its skin in are exactly the ones a collapse has already made the longest. A deferred plate still wears its collider, so a piercing round crossing it still burns a layer.
  • ShipSkinPlugin { render } is split at the render line, not at the look line: the derivation and the shed 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 parts a mod brings 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 ShipDesign’s presentation 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 StylePalette - a SurfaceFinish for the plate top and one for its wall, the hidden floor keeping an engine finish - and a list of decoration fixtures, each with a model AssetRef, a health, a collider and a FixturePlacement. 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.

A FixturePlacement is three authored words - a FixtureRegion, a FixtureDensity and a FixtureOrientation - and skin_style expands it PRIVATELY into the ScatterRule written in the vocabulary above. That expansion is engine policy: it is tuned against real hulls and it moves when the plate derivation moves, which a style that had spelled out fourteen interacting filters could not survive. Two facts are derived from the fixture’s own collider rather than authored beside it, because the size already answers them: min_run (one cell of run per half cell the piece spans in plane, capped at MAX_DERIVED_RUN) and whether the piece needs a whole seat at all (a collider under TRIM_HEIGHT is trim, and trim beds onto a crease). Physics density is not authored either - every piece stands at DECOR_DENSITY, the same as the cladding it sits on.

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 takes UNCLAIMED plate first. Where a thin rule’s whole region was swept by broader rules below it - Rare on a Panel two Dense rules also want - there is no unclaimed plate to floor onto, and the rule logs x0 of 78 on a hull it was eligible all over. So the floor may BORROW one claimed plate, under three conditions that bound the damage: the holder is on a THICKER rung than the borrower, the holder has a floor of its own (Every never lends, and a line with a hole in it is not a line), and the holder still keeps a piece somewhere on the ship. Priority therefore decides who picks, and the floor decides only that nobody is picked down to nothing.

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 nine 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 contact impact damage, taken from the impulse avian’s solver settled on and spent only on the approach above a universal 5 m/s safe contact speed.
  • 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 nine) - 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) and pyre.rs (PyrePlugin) - what a destroyed body DOES and what it LOOKS like: the wreckage that drifts away, and the fireball it drifts out of. 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 sized by how deeply it was buried 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.

The FIREBALL is the other half, and it is a separate plugin because it rations what explode does not. pyre.rs observes the same destroy marker and spawns two hanabi instances - a core and its ejecta - at two authored sizes: a section burns at SECTION_PYRE, a whole hull at HULK_PYRE.

A section is the same size on every ship, because a build-grid cell is. A HULL is not, so HULK_PYRE is the look and the dying root’s IntegrityEnvelope is the size it is drawn at: every length is multiplied by the hull’s containment radius over the 55.2 m gunship the look was cut on, and the flash by the square of it, because lumens stand in for a burning surface. The shipped hulls run from 0.88 on a skiff-sized hull to 3.52 on a carrier-sized one, so a carrier’s debris reaches 479 m rather than ending 58 m inside its own wreck. Durations and particle counts are NOT scaled: a bigger ship does not burn for longer, and the count is what the frame budget was written against. A body that publishes no envelope gets the authored death rather than no death.

Hanabi bakes a Gradient into the asset, so a baked size curve cannot be multiplied by a per-instance property. Every size curve in pyre.rs is therefore an expression over the particle’s own normalised age, built by key_curve from the same keys a gradient would hold, and multiplied by one hull_scale property written per death - the same shape the railgun wake uses.

Almost nothing about those graphs is built at the death. warm_the_pyres runs in Update, on the first frame that has all three of a cold store, a tier that draws particles at all, and an active camera - and it does the whole job: the four graphs, the shared soft-dot mask, and one hidden instance of each graph. The instance is the part that matters, because bevy_hanabi generates a shader from a spawned INSTANCE and never from an asset, so a warm-up that only filled the asset store left the WGSL to be generated on the collapse frame. Those four are silent, invisible, and despawned the next frame. A tier with particles off builds none of it and lights nothing.

Update and not a state transition, because deaths are not confined to Playing: the main menu’s backdrop is a scenario whose whole loop is a torpedo erasing a ship, and that backdrop raises its camera frames before its first death. The tier is settled well before the gate opens - the settings pass applies in PostStartup and again on change, both inside SettingsSystems, which the warm-up orders itself after - and the cold store is what lets a tier RAISED from the pause overlay reach the warm-up, which no OnEnter carries.

The camera is a requirement and not a preference. hanabi gives every live instance a row in one BufferTable of draw arguments and reconciles that table only on a frame that has a view, so four rows taken and given back with no view in between leave it allocating a 60-byte buffer and writing 80 bytes into it: a panic in the render schedule at boot. Warming in PostStartup is exactly that shape, because that is before any scenario has spawned a camera.

The ALMOST is one pipeline. A hidden instance is compiled and gets its two compute pipelines, but bevy_hanabi specializes the RENDER pipeline only for entities a camera can see, so the first death of a run still creates one - on the render thread, because nova_core asks for synchronous_pipeline_compilation on every backend. Warming that would take a VISIBLE instance, which a warm-up is not: these are hidden exactly so that nothing draws them.

What a death still mints is instances, so this half DOES need a budget. A compartment death is QUEUED, and the whole frame’s batch is spent at the end of it: 6 * sqrt(condemned / 53) fires, floored at six and capped at 48, picked by farthest-point sampling over where the deaths happened. The reference is the 53-section gunship the look was cut on, so an ordinary frame still lights the six it always did, a 720-cell corridor lights 23 and a whole carrier 38. The sampling is the half that matters: deaths arrive in section-graph order, so the first six of a collapse are six fires in one shoulder of the wreck - the chain has to be cut from the batch and spread over it, not taken off the front. A root’s own fireball is never queued and never dropped, because that is the one the whole death reads as. Material has the last word - IntegrityDestroyMarker is a shared seam that an exhausted asteroid also raises, and rock does not burn.

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.
  • How far clear is measured on the piece, not on the constant. A piece has to cross the structure standing over it, plus its own reach, plus 10 m of daylight. The kick and the grace window are both multiplied by the square root of that distance over 10 m (clearance_scale), so the SLOWEST piece is exactly outside as its window runs out: a plate off the skin leaves at 20 to 50 m/s and is physical in half a second, while a section 97 m inside a capital leaves at over 100 m/s and stays a ghost for 1.7 s. The depth comes from IntegrityEnvelope, which the layer that owns the body publishes - a hull from its live containment radius, a rock from its own.

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.

What the frame SOUNDS and FEELS like is grouped the same way, by the STRUCTURE rather than by the place. An impact or a destruction cue - the explosion, the surface hit, the camera kick and the spark burst - is throttled on its CueGroup (nova_gameplay::audio::mixing), which is the nearest RigidBody at or above the entity the event named: a section, a plate, a health node and an asteroid’s collider node all hang under one. A severed fragment, a rock and a torpedo each resolve to their own body, so each is its own event, and only a cue with nothing physical over it falls back to a 60 m world cell (SFX_AREA_CELL, six world units). That cell used to be the whole rule, and a cell is about the size of a gunship, so it was the same mistake in both directions: a carrier collapse spanned two dozen cells and threw 25 trauma kicks in the frame it came apart - the pile-up the throttle exists to stop - while two hulls dying a few metres apart shared one death. The same collapse throws 3 kicks now.

WHERE it plays is the other half. A death is emitted from the body’s centre of mass, not from the section that happened to be raised first, because the sections a collapse raises first are the ones nearest the wound and not the ones a listener is watching let go. A HIT still plays at the contact: that is a real place on a hull the listener can see being struck. Audio and juice share one resolver, so a collapse is one explosion and one kick from the same point.

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 a modelled 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 kick away from the cut. Its raw speed is the fragment’s own containment radius over two seconds, floored at 10 m/s, so two halves open a gap as wide as themselves in about two seconds whatever they are: a carrier’s halves part at tens of meters a second where a cockpit shard takes the floor. 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 engine world unit - per 1000 m^3. It is an INVERSE volume, L^-3, so it is not a length that could be re-expressed by multiplying by ten, and converting it would move the whole damage-to-crater exchange rate; it stays engine-side beside the signed field it prices, which is engine geometry too. 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. It answers in world units, like everything else the field is cut in: the shipped kinetic PDC round (4.0 damage) carves 0.62 u, a 6.2 m crater.

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 as an absolute length and not as a proportion of the body. The three caps below are in the field’s own engine world units, which is the frame the marks are kept in.

  • MARK_MIN_RADIUS 0.15 (1.5 m): 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 (10 m), 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.12 world units - a 1.2 m chip) that live 2.5 s, 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 bevy_hanabi PARTICLES, not entities: a carve aims one of a per-material pool of emitters (grown on demand to SHARD_EMITTERS per material, which is also the most craters one frame chips) and the GPU throws the cone, tumbles, cools and ends them. Nothing on the CPU stands for a chip - no rigid body, no collider, no mesh - which is what a busy fight paid for when they were kinematic bodies; a range reads CarveShardTally for what was thrown. 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 world unit, 1000 m^3 - a volume, so it is engine-side with the field) 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 interface’s section LABEL (nova_interface reads it for codes, icons 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, which is 100 engine world units per second because it is compared against avian velocities - the same speed the shipped PDC authors as muzzle_speed: 1000 m/s - 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 power is the only bound: cheap plating lets one round chain through many layers. A layer that costs no power stops it.

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; the turret/torpedo/railgun config’s ammunition: AmmoCapacity::Limited(n) opts in (Unlimited is the default). Unlimited fire is authored on the GUN, never granted ship-wide: a config left Unlimited (see SectionConfig::without_magazine) is the authoring route, and the armed ammo infinite command / SetInfiniteAmmo action are the runtime ones (they strip SectionAmmo and SectionReload, recording them in SuspendedSectionAmmo to restore full on the way back off).
  • SectionReload (sections/ammo.rs): optional idle batch reload on a magazine, from the turret/torpedo/railgun config’s reload: ReloadConfig::Batch(SectionReloadConfig { delay, amount }) (Disabled is the default). Seeded with the weapon’s ammunition ItemType (PdcRound for a turret regardless of mount damage type, RailSlug for a railgun, Torpedo for a torpedo bay regardless of bay type). Every successful shot resets progress; every completed quiet delay moves min(missing, amount, parent ShipInventory's count of that item) from the parent ship’s ShipInventory into the magazine - rounds and items change together, so the pair is conserved, and with none of the item in stock the delay does not advance. A full magazine reads no inventory, so the ship editor’s preview sections, full under a view entity, tick safely. A section below capacity must have a parent carrying a ShipInventory (every ship root requires one) or tick_section_reload panics. 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, and is_reloading() folds the reserve check in so a dry ship shows no reload.
  • 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; the fireball over it, SECTION_PYRE, HULK_PYRE, frame_cap, warm_the_pyres - crates/nova_gameplay/src/integrity/pyre.rs; what it sounds and feels like, CueGroup, cue_body, body_middle - crates/nova_gameplay/src/audio/mixing.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.
  • The spinal lance: RailgunSectionConfig, RailgunCharge, charge_and_fire_railgun - crates/nova_ship/src/sections/railgun_section/; its sight: BoreSightPlugin - crates/nova_hud/src/bore_sight.rs; its AI commit envelope: crates/nova_ship/src/input/ai/railgun.rs.
  • Derived skin and styles: ShipSkinPlugin - crates/nova_ship/src/sections/shell_skin.rs; ShipStyleConfig - crates/nova_ship/src/sections/skin_style.rs; the shed and its cap, shed_dead_fixtures, SHED_TICK_CAP - crates/nova_ship/src/sections/fixture.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, each a launchable chapter - naming a non-Chapter scenario is a lint Error); 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. The camera opens on the authored PLAYER spawn, in the chase rig’s own pose for that hull’s envelope (chase_camera_opening_pose), so the first frame is already the shot the live rig will hold; a scenario with no player falls back to 100 m up and 200 m back from the origin. 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: 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 ShipDesignSource 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. The budget is a NO-PROGRESS one (PRELOAD_STALL_SECS, 10 s): every handle that settles buys the whole budget again, so a big catalog on a cold disk takes as long as it takes, and what the budget catches is an asset source that has gone silent. A handle that fails, or a load that stops moving, fails the load CLOSED - see below.

Ships are the only object kind involved. A beacon builds 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 load is atomic (loader/gate.rs)

A scenario used to start the moment its config landed: its objects arrived over the following frames, its art after that, and the player had the helm through all of it. Rocks popped in around a ship already flying, mission time had already run, and a collision could happen against a body still spawning.

ScenarioLoadGate closes that. From ScenarioLoaded until the frame the scenario is built it is Loading, and while it is:

  • the simulation is HELD - Clocks::hold(FreezeOwner::ScenarioLoad) pauses Time<Virtual> and Time<Physics>, so the scenario clock, physics, timers and every Time-driven system stand still;
  • gameplay input and both camera rigs are gated off (scenario_play_is_free on SpaceshipInputSystems, SpaceshipSectionSystems, NovaCameraSystems and WASDCameraSystems::Sync) - holding the clocks alone is not enough, because a chase camera reads the mouse and a free-fly camera reads held keys on REAL time;
  • Time<Real> keeps running, so the LOADING panel animates.

The release is scenario_has_settled PIPED into release_when_scenario_is_built, which is the same condition the panel’s own dismissal reads: the panel cannot come down over a held world, and the world cannot start behind a panel. The frame that releases is the last frozen one - its delta was taken while the clocks were held - so the first frame the player is given is a complete scene at scenario time zero.

A load that FAILS never releases. An explicitly failed handle, or a load that stops making progress, calls fail_scenario_load: the gate goes to Failed, the hold STAYS, and the paths are reported through ScenarioStartFailure - the same FAILED TO START overlay the content gate uses, which offers Main Menu and nothing else. Continuing with placeholder art would hand the player a scene that is not the one the author wrote, with no way to know it. Leaving through UnloadScenario is the one path out of Failed, and it drops the hold with the scenario that took it.

A load the content GATE refuses never takes a hold at all - on_load_scenario lints the config in hand (start_errors) and returns before ScenarioLoaded fires. A refusal ENDS the scenario it was asked to replace: the teardown runs and CurrentScenario is cleared BEFORE the report is published, the LOADING panel comes down on the spot, and the report is drawn on a 2D camera of its own, with the cursor freed for the one button on it. Nothing is left to simulate behind the modal, which is the point - the report is a dead end, and the scenario it interrupted is not a fallback the player can be handed back. The campaign case is a chapter whose next chapter a mod removed.

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 / Order / Section 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, the hull docked with against the hull that asked), which is why the filter is one struct rather than one per event. Lock, orbit, docking and player STOP/GOTO completion events are one-shot EDGES with no hidden timer behind them: a target switch queues end-old then start-new, a successful GOTO reports only after physical settle, 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. Six 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.
  • SetCameraAnchor is the same override, SOLVED every frame instead of once: ScriptedCameraAnchor names an entity, an offset in its local or the world frame, and what to look at, and track_scripted_camera_anchor rebuilds the pose from wherever that entity now is. ScriptedCameraTransform is the one thing Override enforces, so the two pose kinds compose - swapping one for the other never drops the camera. Losing the anchor entity releases the override rather than freezing the shot, and a look_at entity that dies falls back to the anchor. It is camera authority ONLY: it issues no helm order and does not change input authority. ReleaseCamera removes both pose kinds. There is nothing to restore because the scenario camera IS the player’s chase camera - the chase sync keeps writing in CameraAuthoritySystems::Solve the whole time the override is up, so dropping the override hands the view straight back.
  • SuspendPlayerControl sets the shared PlayerControlSuspended gate and immediately clears held burn, RCS, rotation, radar, combat stance, and weapon intent. Player input observers also read that gate because observers bypass system-set run conditions. ResumePlayerControl lowers it. Both are idempotent, and scenario teardown always resumes control. The flight context is lowered while suspended; Always remains live for pause, menu, and an explicitly always-live cinematic-skip binding. Physics, timers, AI, scripted orders, and the cinematic do not use the player gate.
  • 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 - audio, cinematic, flow, mission, sequence, ship, spawn, timer, view - plus registry, which is the macro and the table rather than a family. Adding one is Extend the scenario engine.

A beat chain keeps its cursor in the engine

A BEAT CHAIN is an authored LITERAL key plus an ordered list of SequenceStepConfigs, and its state does not live in the action. Running one 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.

Two actions are chains. Sequence calls NovaEventWorld::start_sequence; Cinematic calls start_cinematic, which files the same run through the same start_run and adds the ending it owes - whether the player may leave early, and the events the scene reports when it finishes or is skipped. Name the mechanism after the CHAIN, not after Sequence: an arm that nests steps is declared in exactly one place, EventActionConfig::step_chain, and every walker in the tree reads it. Add a third chain there and nothing else has to learn about it.

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, every walker that reasons about frames has 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 walk_mut, for a pass that rewrites) and ScenarioEventConfig::action_groups, which returns a handler’s own actions plus one group per step of every chain 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 the frame’s budget is spent: SPAWN_DRAIN_BUDGET (3 ms), or a fifth of the previous frame’s wall time when that is more (SPAWN_DRAIN_FRAME_SHARE). A big scene arrives over several frames, a slower machine takes MORE FRAMES, and a frame never gives the drain more than the fifth of itself a 60 Hz frame does - without the share, a machine drawing a frame a second landed one object a frame and kept a chapter’s ships a wall-clock minute behind its scatter. 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. It is also what the load gate releases on, so the panel, the clock and the simulation hold all come down on one frame and one condition.

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. The menu’s one-second cut after a backdrop’s BackdropDone (nova_menu::ambience:: advance_menu_backdrop) ticks on the same clock. The scenario raises the flag in the PostUpdate event drain, and the menu reads it in the next Update.
  • 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

NarrativeCue writes into a bottom-left comms stack rather than a latest-wins line: arrival order, a bounded number of cards visible, and a bounded pending queue behind them. The whole log stays in the feed either way. 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. The backlog holds 24 waiting lines, which is what three cards can reach inside the 30 s a line stays worth reading; past that the OLDEST waiting line is dropped and the overrun is logged once.

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.

The Ledger’s field trials show both patterns. In webmods/the-ledger/ledger_05_freight_lane.content.ron, the victory gate reads arrived and stage. In ledger_03_blue_survey.content.ron, an active scan writes scan_left for its HUD countdown. That write keeps the pulse awake until the scan ends or the player leaves its area. Neither pattern needs an authored wake list.

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 tutorial range 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. content lint checks the bundle schema, ids, references and shared balance rules. The Gauntlet geometry invariants - non-overlapping gate areas and racing-line clearance past each rock’s worst-case body - remain manual checks.
  • 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 only the asteroid and the planet opt 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.

Four engine facts the object configs do not show:

  • BodyRadius is DERIVED from the mesh, not read from the config. Both ground kinds publish radius * unit_extent, and the two extents are nothing alike: an asteroid’s noise mesh puts it in [3.5, 6.0], a planet’s is 1 + relief, about 1.05. Everything measured off a body - the gravity well’s clamp, the sphere of influence, an orbit ring, a GOTO standoff - reads that derived number, so moving content between the two kinds means carrying the DERIVED radius across, not the authored one.

  • 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.

  • What a rock is MADE of is required, and it decides how it looks. AsteroidConfig::kind names a KIND - rock, metal, ice, carbon, or the plain control - and objects/asteroid_kind.rs is the one table that maps a kind to a look. It decides the LOOK and nothing else: a rock’s hit voice is ImpactSurface::Rock whatever it is made of. The look is uniform data for a triplanar ExtendedMaterial (objects/asteroid_surface.rs), so a carved and remeshed rock wears the same surface it did before the hit. There is NO default and no fallback: a config without the field fails to deserialize, an id the table does not know is a content lint error and a refusal to render, and ScatterObjects writes a kind into every copy from an authored weighted mix.

  • 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. It has no mass field either: an asteroid never SOURCES a gravity well - only a Planet or an Anchor does - it only ever FEELS one, as a plain dynamic body. What it requires instead is an explicit initial_velocity (MetersPerSecond3), same as a Spaceship’s: checked finite at lint and again at load, with MetersPerSecond3::ZERO an authored stationary start, not an exemption from gravity. The base-world generator seeds a circular orbit only when its full path stays clear of other wells and within the well’s owning sector. Otherwise it seeds an outward escape inside one well or a zero-velocity coast in free space, by the body’s position (nova_world_base/clusters.rs::generated_initial_velocity); a body reachable by two wells at once fails generation outright rather than blending them. mass moved to Planet (now REQUIRED) and stayed optional on Anchor (None is a zero-strength well, pure geometry). Both still carry the same mu, an L^3/T^2 quantity - an SI one would be a THOUSAND times the world-unit one - so it stays engine-side in u^3/s^2 with the GravitySettings cutoff it is measured against. It sets both the pull a = mu / r^2 and the sphere of influence, the distance where that decays to GravitySettings::soi_cutoff_accel (0.25 u/s^2, which is 2.5 m/s^2), so a well is authored by the SOI it should have: mu = soi_cutoff_accel * soi^2, both terms engine-side. The inspection planetoid’s 27 000 buys a 328.6 u sphere of influence, and 328.6 u is the 3.29 km the HUD reads. A negative or non-finite mass is a content lint error and a spawn refusal.

  • 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. The field is what the rock is meshed and collided from, so it is engine geometry: every figure in this paragraph is in world units, and the authored Meters radius crosses once on the way in (pristine_rock_mesh). FIELD_CELL_WORLD is 0.5 WORLD units - a 5 m cell - 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 u, a 6.2 m sphere, 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 30 m rock is a 1.02 u 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 an authored radius of about 18 m, and what it costs there is the cell (a 30 m rock is gridded at 0.82 u, 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, tutorial/ builds the New Game starter, Basic Training, across mod.rs (the chain), range.rs (the props) and script.rs (every line), and pacing.rs holds the cue and outro spacing. Basic Training is 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, the bundle manifest 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.

The orbit tracker derives lifecycle edges from live autopilot state. Once the ship first reaches Hold it also sums signed radial-angle changes around the sticky orbit plane, and each net revolution fires OnOrbitLap. Counting starts at the RING, not at the verb, because the insertion approach curves around the well and would otherwise bank most of a lap for free. It then survives every phase the autopilot flies there - Align and Burn during ORBIT are the correction that puts the ship back on the ring, not a departure from it - and is written off only by an absence from Hold longer than ORBIT_LAP_GRACE_SECS. So authored lap objectives need no guessed timer, and one nudge does not silently cost the player three quarters of a revolution.

The docking tracker reads its two edges off the CONNECTION entity rather than off either hull. DOCK builds exactly one connection per pair and every way out of a dock - either pilot’s verb, a shot-off port, a dead ship - despawns that one entity, so comparing a live connection against the DockEcho BOTH hulls carry reports one OnDocked and one OnUndocked on every path, with no branch per ending and nothing in nova_ship knowing what a scenario event is. The echo retains the payload, not the two entities, because the release edge has to name a partner that may already be gone - which is also why both hulls carry it: a pair whose asking hull is destroyed still reports through the survivor, and the two echoes are deduplicated into one release. Only a pair that dies whole falls silent, the way orbit end and area exit do for a despawned body.

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/docking 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 (audio/cinematic/flow/mission/sequence/ship/ spawn/timer/view), plus a row in the registry::scenario_actions! table in actions/mod.rs. The EventActionConfig variant is GENERATED from that row - writing one by hand collides with the macro.
  • 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; the load hold and its failure: ScenarioLoadGate, scenario_play_is_free - crates/nova_scenario/src/loader/gate.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.
  • Asteroid kinds: AsteroidKindId, AsteroidKind, asteroid_kind_look, ASTEROID_KINDS - crates/nova_scenario/src/objects/asteroid_kind.rs; the surface they drive: AsteroidSurfaceMaterial - crates/nova_scenario/src/objects/ asteroid_surface.rs and assets/shaders/asteroid_surface.wgsl.
  • 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 NOVA_AUTOPILOT_DEADLINE sized to the capture window for its fps pass and to --timeout for its behavioral 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, its weapon locks and its capabilities - each ship’s sections with their class, pose, health 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 and the IN-STEP frame index
untilthe predicate that advances the step
deadlinein-step seconds after which an unsatisfied until ABORTS the run, naming the step
diagnosea reader of the world appended to that abort message, so the stall says WHY

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, and there is no second mechanism beside the step list: “wait N seconds in this state” is step("...").enter(state).until(elapsed(secs)), written out where anyone reading the script can see what it waits for. The vocabulary is in nova_autopilot::predicate (elapsed, frames, state_is, resource_where, any_entity, ui_node_present, pointer_pressed, pointer_released, pointer_at, pointer_at_node, window_size_is, window_scale_factor_is, 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, click_named, 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 four waits, not four sleeps

A click on a widget owes the app four 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
aimpointer_over_node(name)a resolvable rect is not a clickable widget: an overlay on its way out takes every pick, and a reflow moves the widget out from under an aim already sent
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.

A headless range that clicks UI must SPAWN A WINDOW, as system_headless_drag does. nova_core holds UiSystems::Layout while any UI root is aimed at a target with no area - a zero-size target makes taffy hand bevy a negative node size, which panics - so an app with no window at all never lays its UI out and no ui_node_present beat can ever advance.

The aim beat re-hovers EVERY frame rather than holding the coordinate it first sent, so a reflow moves it along instead of stranding it; it carries pointer_hover_diagnosis(name), which names the occluder that took the pick.

Those four beats are one builder, not a shape each script re-types: AutopilotPlugin::click_named(label, name, landed, deadline), with double_click_named beside it. landed is the caller’s own condition - what the click was FOR - so a click whose effect never arrived stalls on the beat that made it. pointer_released() is the weakest thing to pass and the right one where the effect is waited on by the beat after.

The layout beat carries ui_node_diagnosis(name), so a stall there says which of the four ways ui_node_present can be false actually happened: no such name, a name hidden by its ancestry, a visible node with no laid-out box, or no UI node type registered at all. Those are three different bugs with three different fixes, and the deadline message used to name none of them.

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.

The probe exists only in a debug build, which is the only build that has those predicates: nova_debug and the snapshot are linked by the same feature. A shipped editor does not carry it, and does not pay the document sweep that fills it.

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.

What a frame count is for

frames(n) stays in the vocabulary, with a narrow job. A frame is not a unit of work - the same count is milliseconds on a workstation and most of a second under lavapipe - so a count is only honest where the thing being waited for is counted in frames. Four cases qualify:

CaseExample
render, raster or shader work coming to rest before a shotframes(SETTLE_FRAMES) ahead of shoot
deterministic media durationthe loop_* ranges, where LoopProfile pins the frame clock so a count IS a webm length
a verdict that NOTHING happened“the pointer stayed put while a stray moved” has no positive event to wait on
deliberate separation between an action and the assert that tests itkeeping a beat strictly weaker than the claim it precedes

Everything else is a guess at how long hidden state takes to change, and the state is nearly always readable: a window resize lands in Camera::logical_viewport_size, a DPI change lands in ComputedNode::inverse_scale_factor, a shell command lands in the terminal’s own scrollback revision. Wait on the landing. frames(1) in particular is nothing at all - a step with no until already advances on its first driven frame - so it says only that its author wanted a step.

Deadlines come from two shared constants rather than a copy per file: STEP_DEADLINE_SECS (30s, a world condition) and BEAT_DEADLINE_SECS (20s, one gesture), both in nova_debug::harness.

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()
        .step("hold")                      // a runway, unrelated to Loading
        .enter(GameStates::Loading)
        .until(elapsed(30.0))
        .add()
        .input(script),                    // every frame, in every state
);
app.add_systems(Last, guard_script_completion);

fn script(world: &mut World, elapsed: f32, _frame: u32) {
    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.

A loop also records what it HEARD, with no extra step calls. DebugPlugin observes the recorder’s LoopCaptureStarted / LoopCaptureFrame / LoopCaptureEnded events and samples each voice’s engine-resolved VoiceMix immediately before each frame request. At loop_end it stages the audio and writes the sidecar, and the drain that follows encodes the WebM:

  • <name>.webm with VP9 video and an Opus track, rendered offline from the loaded samples at each frame’s pre-master VoiceMix::channel_gains and speed. A positional voice plays the sum of its sample’s channels at its per-ear gains, as the spatial sink does; a flat voice keeps its channels. A voice that the voice cap or a frozen sim holds is silent and paused. A one-shot plays its whole sample; a loop ends with its last frame. If the summed PCM peak is over -1 dBFS, the whole loop is attenuated once to put it there before the Opus encode, which can overshoot it on decode; a quieter loop is never boosted. The log names the original peak and the attenuation.
  • <name>.jsonl, the version 1 SFX sidecar for the editor: a header {version, loop, fps, frames}, then {f, v, gain} rows with clip and looping on a voice’s first row. gain is the pre-master sink gain, the editorial contract, so it carries neither the pan nor the WebM’s peak attenuation. A held voice’s row has gain 0. Speed is not in version 1.
  • each named sample at its asset-relative path beside the sidecar. A sample that cannot be copied warns. Any other sidecar or audio failure, such as a voice with no asset path, a path that is not plain relative names, a path under the .loop-frames staging directory, or an existing symlink, non-directory parent or uninspectable entry on a sample’s path under the capture directory, aborts the run before the encode, so loop_written never acks a silent loop. The destination check runs before any write but does not stop a link created during the copy, and it does not detect a hardlinked sample file. The sidecar and samples are on disk before the encode, so an ffmpeg mux failure after them fails the run but leaves the complete sidecar.

A Bevy-only app with LoopCapturePlugin and no DebugPlugin still writes a silent WebM. Sheets record no audio.

A SPRITE SHEET is the same recorder with a grid for a length. The training handbook draws a lesson loop from one image of cells - the game has no video decoder, so an atlas is how a panel animates - and a producer opens the recording with sheet_start(world, name, grid) and holds the closing step on sheet_written(name). There is no sheet_end: the grid IS the length, so the recorder stops at SheetGrid::frames() frames, tiles them left to right and top to bottom, and acks <name>.png itself. Calling loop_end on a sheet is an error that names the predicate to hold on instead.

#![allow(unused)]
fn main() {
sheet_start(world, "flight_aim", SheetGrid {
    columns: 4, rows: 5, cell: (960, 540),  // the grid the LESSON authors
});
}

The cells are recorded frames, so what happens under a sheet has to survive the WRAP: a sheet plays on a cycle, and twenty frames at ten a second is two seconds of it. examples/screenshots/shared/lesson.rs carries both answers. A POSE loop freezes the scene and puts the camera on one period of a sine arc, so the last cell hands back to the first. An ACTION loop holds the camera still and lets the act be the motion - a few cells before, the press, and the rest of the sheet holding the result - which wraps the way a tutorial clip wraps. When the act is the SHIP MOVING, a camera fixed in the world loses it inside two seconds, so LessonChase holds a fixed offset IN WORLD AXES from the moving hull: the ship keeps its place in the cell and its attitude on the screen, and a hull the flight computer turns end for end is SEEN to turn. What does not work is a one-way clip of gameplay under a camera bolted to the hull’s own frame: that shows a still ship with the background spinning round it. scripts/capture-lesson-media.sh runs the lesson_* producers and packages what they write into assets/base/training/, encoding the staged PNG as the WebP the handbook ships - a sheet sized to fill the Lessons pane is megabytes lossless and about 150 KB encoded.

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 REAL 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.

Real seconds, not the app’s: Nova pauses Time<Virtual> behind the pause overlay and the TAB interface, so a deadline measured on the game clock stops counting the moment a walk opens either - and a beat that stalls there holds the run open with nothing naming the step. Both clocks that time a run read Time<Real> for that reason: the step deadline, and the run-level watcher’s own elapsed.

One exception, and it is by design: an ARMED run pins the REAL clock to the loop profile’s frame duration, so every deadline on it counts RENDERED FRAMES over the profile fps and not wall seconds. LoopCapturePlugin::build inserts the pin as soon as NOVA_CAPTURE is set - at plugin build, before any loop opens - so the pin covers the armed run END TO END and a beat outside a loop is frame-clocked too. Budget every beat of a capture script in frames, and expect a slow capture host to spend longer in the room than the deadline names. A pre-loop beat sized in wall seconds is silently a frame count: loop_command_shell’s .deadline(30.0) on its load step is 900 frames at the default 30 fps.

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_hero_ship --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.

The agent bench

How an external agent plays a scenario or starts New Game toward a goal with no human at the keyboard, and how the run is scored. The bench is the bench subcommand of the game binary, built with --features debug, and lives in crates/nova_bench with one TypeScript relay in tools/nova_bench/pi/.

                 referee protocol                 process channel
  +-----------+  (unix socket, JSONL)  +------------+  (stdin/stdout JSONL)  +----------------+
  |  agent    | <--------------------> | nova_bench | <--------------------> |  nova-protocol |
  |  (pi, a   | observe/act/page/finish|  (referee) |  tick / input / aim ...|  --norender    |
  |  script)  |                        |            |  <- snapshot           |  --channel step|
  +-----------+                        +-----+------+                        +----------------+
                                             |
                                 audit.jsonl, score.json, log

Three processes, one referee. The agent never touches the game and the game never sees the agent. nova_bench sits between them: it owns the clock, condenses what the agent sees, logs every line in both directions, and scores the run from the game’s own snapshots. An agent cannot grade itself, because the referee reads the world, not the agent’s report.

The game side is the process channel (nova_channel, see Architecture) in --norender --channel step mode: the world moves only inside a step, so an agent may think for a minute and its observation never goes stale.

nova_bench depends on no nova_* crate. It speaks JSON over pipes and a socket, so its tests link neither Bevy nor the game.

Running a play

cargo run --features debug bench play <scenario> --agent baseline
cargo run --features debug bench play <scenario> --agent pi --model gpt-5.6-luna --thinking low
cargo run --features debug bench play <scenario> --agent 'cmd:python3 my_agent.py'
cargo run --features debug bench play --session new-game --world-seed 424242 --seed 7 \
  --agent pi --goal 'Explore the open world and report what you find'

<scenario> is an installed scenario id, or a path ending in .ron loaded as a loose content file. --session new-game takes no scenario. It starts at the real main menu; the agent clicks New Game, enters --world-seed in the setup modal and clicks Create. New Game requires both --world-seed (the u32 world-generation seed) and --seed (the separate u64 gameplay RNG seed), plus --agent pi or --agent cmd:.... The combat-only baseline agent cannot navigate the menu. A scenario cannot be combined with New Game, and --world-seed is not accepted for fixture plays. New Game uses the base bundle’s open_world scenario; naming open_world as a scenario does not create the required world session.

The bench ships six fixtures under crates/nova_bench/scenarios/:

  • hunt.content.ron: one player gunship, one hostile inline raider parked 2600 m ahead, an objective and both outcomes. The scenario scores itself.
  • range.content.ron: an open range with no objective and no victory. A 600 m planetoid 7 km ahead, two rocks, a nav beacon, and an unarmed hostile derelict support navigation, orbit, parking, and PDC goals.
  • slingshot.content.ron: an offset gravity well, a distant exit mark, and a hostile pursuer that wakes after 45 seconds support gravity-assist, evasion, collision, and route-cheese goals.
  • arsenal.content.ron: a player hull authored inline with a railgun, two torpedo bays and two PDC mounts, plus a carvable rock, two inert targets and a late picket, supports mixed weapons, asteroid carving, the command shell, cheat, and protocol-abuse goals.
  • docking.content.ron: the docking_approach example as a fixture. A tender with a port on its bow and an inert spar 120 m ahead, turned out of square, support docking and RCS goals; me.docking carries the approach.
  • docking_warship_tender.content.ron: the catalog Line Warship and an inert damaged frame tender, their collars 115 m apart and 12 degrees out of square, support docking captures between real multi-section hulls.

These six fixtures are bounded by --ticks and --deadline; a reader grades the end block of the score and any recorded footage. The red-team rules are in crates/nova_bench/scenarios/README.md, and the goal deck itself is scripts/bench-plays.sh, which runs any of twelve named plays with its goal, seed and budgets already set:

nix develop -c scripts/bench-plays.sh --list
nix develop -c scripts/bench-plays.sh sommelier shell cheat

For these fixtures, --seed pins the world, not the run: the model is the other half and takes a different route every time. The script pins the setup so two plays of one goal are comparable, which is as close to reproducible as an agent play gets.

FlagDefaultMeaning
--goalfixtures: complete objectives; New Game: create the world, fly and explorethe goal text the agent is given
--model, --thinkingunset: pi’s own configured model and thinking levelpassed straight to pi; pi agent only
--seedunset for fixtures; required for New GameNOVA_SEED for the gameplay RNG; it is not the New Game world seed
--world-seedunset for fixtures; required for New GameThe seed the agent enters in the New Game modal
--ticks18000the tick budget, five game minutes
--turns300the act budget
--deadline1800wall-clock seconds
--outbench-runs/<sha>/<target>/<agent>-<n>/the run directory (<target> is the scenario label or new-game)
--recordunsetrecord each stepped tick offscreen into an empty directory, with SFX and an action-rail <dir>.mp4; see The movie
--uiloglog prints one line per event to stderr; quiet prints nothing
--audit-rawoffkeep the full snapshot in every channel_in event

New Game’s initial observation has no player ship. On the main menu the agent’s view includes the visible named UI targets so it can find the New Game button, seed field and Create button. The agent must replace the modal’s prefilled seed; merely appending digits does not select the requested world. Menu actions use the same pointer, text and key gestures as other UI actions. The referee checks the world’s actual session seed before gameplay scoring. A missing player at menu start is expected; a wrong seed or scenario fails setup, and a run that ends before setup completes does not count as a completed play. Menu-backdrop ships do not count as combat kills. The audit retains menu steps and the launch target; replay starts at the menu and checks world-seed identity as well as the final flight state. A free-mode goal has no authored Victory: judge the world state and the score, not the agent’s report alone.

The run directory holds audit.jsonl, score.json, game.log, agent.log (a cmd: agent’s stdout and stderr) and profile/, an empty settings profile the child game is pointed at so a play never reads or writes yours. The command prints the run directory and the score table on stdout and exits non-zero when the game or agent process fails, or New Game setup fails or remains incomplete. A Defeat alone does not make it fail.

The referee protocol

JSONL request and reply over the unix socket named by NOVA_BENCH_SOCKET. One request per connection, one line each way.

RequestReplyMeaning
{"observe": {"expand": [...]}}{"ok": <view>}The pilot’s view now. Free: the clock does not move. expand names body groups to open in full.
{"act": {"gestures": [...], "ticks": N}}{"ok": <view>}Apply the gestures, run N ticks (default 30), return the view. The only way time passes.
{"page": {"name": "targeting"}}{"ok": {"page", "text"}}One page of the flight manual. Free.
{"finish": {"status": "done" or "gave_up", "report": "..."}}{"ok": {"over": true}}The agent is finished. The report lands in the score; the metrics do not read it.

A malformed request gets {"error": "..."} and the run continues. Every view carries over, ended_by and budget_left; once over is true, act moves nothing. New Game setup actions use the same turn, tick and deadline budgets as flight.

A gesture is one object with one verb. The referee stamps the tick, so an agent’s vocabulary is relative and a transcript is replayable.

GestureExpands to
{"press": "<wire>"}one input ... start line; the referee remembers it as held
{"release": "<wire>"}one input ... stop line
{"tap": "<wire>"}start on one tick, stop on the next
{"aim": "<wire>", "delta": [x, y], "ticks": K}K aim lines, one per tick; K is a whole number from 1 to 3600, refused outside that
{"command": "..."}, {"text": "..."}, {"key": "..."}, {"pointer": {...}}passthrough, one line each

Gestures in one act land on the same tick, in order. A held input stays held across acts until released.

One act cannot drive both readings of a shared key. targeting.radar_hold and targeting.radar_clear are one key read two ways - a short press clears, a long one searches - so an act carrying both is refused with a message that says to split it. Nothing downstream could report that: a press has no verdict to give.

Splitting it is not enough on its own. The referee checks the act against its held set too, so driving one reading while the other is still held is refused the same way: the key is down for the first reading, and the second would release that same source behind the referee’s back. The release goes in its own act, before the act that drives the other reading.

The pairs are not a table in the bench. nova_input already models the relation as ActionBinding::follows, the channel publishes it as inputs.shared, and the referee checks the act against whatever the world reported. A game that declares a second shadow action is guarded without an edit here, and one that drops a shadow stops being guarded for a rule that no longer holds.

The view

The raw snapshot is a probe artifact: every hull plate, every ordnance record. The referee condenses it to what a pilot perceives, in meters, meters per second and degrees:

  • tick, game_seconds, game_state, world_seed (null until New Game creates a world), over, ended_by, budget_left, game_errors; ui.targets lists visible named pointer targets, only on the main menu and while the Tab interface is open (ui.pause is Interface).
  • objectives, outcome (null until Victory or Defeat), comms (the last twelve radio lines).
  • me: id, position_m, speed_mps, velocity_bearing_deg, turn_rate_dps, health, cargo (capacity_g, used_g and free_g in grams, credits, and items as {item, count} in the game’s item order, empty for an empty hold), weapons_hot, combat_lock, travel_lock, radar (the acquisition dwell while the radar gesture is held: the candidate, the dwell target, dwell_secs of dwell_needed and the dwell_fill a player watches on the ring, itself null when no dwell is running; null otherwise), autopilot (engaged: action, target and phase, or null; completed: the last action that finished), docking (docked, the connection a clamp holds the hull to, and pair: the approach to the nearest port on the travel lock, as an offset and a bearing in the hull frame with the three capture gates and eligible), gravity_well (the dominant well’s id), sections (the bridge, the drives, each mount with its weapon: kind, ammo, on_target, firing), hull_plates as {total, damaged, lost} and withheld_capabilities (what the scenario has not granted yet).
  • contacts: every other ship with allegiance (Enemy, Player, Neutral), distance_m, bearing_deg, closing_mps, health, defeated, weapons_hot, ai_target.
  • beacons with distance_m and bearing_deg.
  • canisters: every live cargo canister, with a numeric runtime id unique within the run (not stable across revisions), distance_m and bearing_deg relative to the player’s ship, and ordered stacks of {item, count}. Distance and bearing are absent without a player ship.
  • bodies: the asteroids and planets, in three tiers. near and in_the_way carry the full record - kind, radius_m, distance_m to the centre, surface_m to the surface, bearing_deg, closing_mps - and in_the_way adds why. The kind says whether a body can be destroyed: every Asteroid can, no Planet can. Everything else is one summary per {distance band}.{bearing sector} group with a key, a count and the nearest surface, farthest surface and largest radius in it; observe {"expand": ["<key>"]} opens one back up and expanded lists the keys that actually opened, because a key can go stale as the ship moves. See Why the bodies are tiered.
  • ordnance as {inbound, outbound} counts.
  • objective_log: every objective card posted and completed, in order.
  • cinematic (only while a scene plays) as {playing, skippable}, and cheats_marked (only when the run armed cheats).
  • inputs.live (registry wire names that are not locked out, never dynamic section.<id> wires), inputs.held, and inputs.shared: the pairs of wire names that read one physical key. The game declares that relation (ActionBinding::follows) and the channel publishes it, so the referee refuses an act driving both sides of a pair without the bench holding a table of its own.
  • commands (answers to command gestures).

bearing_deg is [azimuth, elevation] from the nose: azimuth positive to starboard, elevation positive up. Registry wire names come from the live set in the view. For section.<id> weapon triggers, use an ID in me.sections[] whose weapon is not null. For a mining emitter, use the ID of a section whose class is Mining and check alive and disabled for working status. Section selectors do not appear in inputs.live or the registry bindings inspector. Section responders can also be thrusters.

There is no refused list, because an input has no verdict to give. Every gate a pilot meets - needs a lock, needs a well, needs the stance, needs range - fires above the input layer, so a press that changed nothing looks exactly like one that did. The channel’s applied block is an ECHO of the lines a frame consumed, {line, input, phase, tick}, and the agent reads the effect off the world: autopilot.engaged, combat_lock, radar.dwell_fill, weapons_hot, bearing_deg, objectives. A wire line the game could not parse is still an error, in game_errors, and always carries a message.

The raw snapshot carries a mission block (objectives, outcome, comms, the objective log, the cinematic and the cheat mark), beacons, bodies, and canisters (runtime ID, engine-unit position and velocity, ordered stacks), plus each ship’s autopilot, radar, cargo and gravity_well; see nova_probe::capabilities::snapshot.

Mining and cargo

Mining uses a per-section hold: find a me.sections[] record with class: "Mining", check its alive and disabled status, then press section.<id> using that record’s id. For the Line Warship’s mining_beam, send {"press": "section.mining_beam"} for a bounded attempt and then {"release": "section.mining_beam"}. The selected emitter must deploy before it can pulse toward a travel-locked rock; reach and aim are checked from its face, not the ship’s centre. The agent view has no carve result or intake position. It does show all live canisters, their contents, and their distance and bearing from the player’s ship. A newly visible ore canister shows ore in the world, not its source or pickup; a canister’s disappearance alone can also mean it was destroyed. The intake takes a whole canister on trigger contact when hold mass permits; only an increase in the relevant me.cargo.items count proves pickup. Jettison (undocked) and Buy and Sell (docked to a trader) are Inventory pane buttons, driven with pointer gestures on the ui.targets the interface shows. Read every result off me.cargo.

An agent finds registry wires in inputs.live; it gets section selectors from me.sections[].id for weapons (weapon not null) or mining (class is Mining). Dynamic section.<id> wires are absent from that live list and from bindings, which inspects registry actions only. For example, bindings flight.main_drive reports that registry action’s keys and whether its context is live; bindings alone lists every registry action by its wire name. bind and bind reset take the bare registry name, such as main_drive. Neither command binds a section wire.

Why the bodies are tiered

A scenario can field a hundred rocks. Enumerated in full they cost more tokens than the rest of the view together - measured over the first tutorial victory, bodies was about 14200 characters of a 20800-character view, two thirds of every observation, over a run that spent 1.18 M input tokens.

Enumeration is also the wrong shape. What a pilot needs about a rock is not that it exists at 8 km: it is what is in the line of sight, what blocks the radar, what is on the GOTO path, and what the ship is about to hit. So each body is classified ALONE, as a pure function of that body and the ship’s frame - no clustering, no seed, no iteration, no memory of the last view - so two observations of an unmoved world group identically and an agent can reason about a group across turns. The bands are a fixed ladder and the sectors are the four quarters, so a rock changes group only when it crosses an edge.

Expansion is for detail, NEVER for safety: anything that can end the run is already in near or in_the_way.

Agents

  • baseline: an in-process Rust policy. It waits for a hostile, raises the weapons, holds the radar until a combat lock stands, closes to 2500 m and holds every mount’s trigger while the lock stands. Zero tokens; it proves the referee, the score and the audit, and is what a CI check would run.
  • pi: spawns pi --mode rpc with the built-in tools off and the relay extension on, sends one prompt (the pilot manual in crates/nova_bench/src/manual.md, the scenario, the goal, the first view; the game knowledge behind it sits in crates/nova_bench/src/pages/, read on demand through the page tool) and consumes the RPC event stream: assistant text, thinking, tool calls and usage land in the audit. NOVA_BENCH_PI names the binary when pi is not on the path. If pi settles with the run still open it is nudged once; a second settle ends the run as agent_exit.
  • cmd:<argv>: any process that speaks the referee protocol. It gets NOVA_BENCH_SOCKET in its environment; its stdout and stderr go to agent.log. The run ends when the process exits.

The run ends when the referee says so

Whichever comes first: the scenario declares an outcome; the tick, turn or wall-clock budget runs out; the agent calls finish; the agent process exits. ended_by names it. After that the game gets EOF (the channel’s clean exit) and pi gets an abort.

The score

score.json, from the snapshot stream only:

MetricSource
outcomethe mission outcome at the end: victory, defeat, none
objectives_seen, objectives_completedids that appeared, and ids that then vanished before any defeat. The live list is sampled once per act, so both also fold in the mission block’s objective log, which keeps a card posted and completed inside one act
ticks, game_seconds, wall_secondsthe referee’s clock
turns, gesturesact calls, and the wire lines they expanded to
damage_takenthe sum of health decreases on the player ship
sections_lostplayer sections that stopped being alive
ammo_spentthe sum of rounds decreases across the player’s mounts; a reload is an increase and does not count
killsEnemy ships that turned defeated
bad_lineswire lines the game refused to parse: an unknown name, an axis driven as a button. The driver writing nonsense, never the game declining to act
cheatedwhether the run armed the command shell’s cheats, copied from the snapshot’s mark
world_seedthe authoritative New Game session seed after setup; null for scenario plays
llmpi only: messages, input, output, cached tokens and cost from the usage events
ended_by, agent_status, agent_reportwhy it ended, and what the agent said
endwhere things stood at the end, for a goal the scenario does not score: the autopilot engaged and completed, the dominant well, speed, the travel lock, the docking state, and the range to every contact, beacon and body

There is no composite number. A composite is policy; the table is the record.

The audit

audit.jsonl, one object per event with a wall-clock t, flushed per line:

  • run_start, run_end (the reason and the score)
  • channel_out (every line sent to the game), channel_in (every snapshot, condensed; --audit-raw keeps the full one beside it)
  • agent_request, agent_reply (the referee protocol, verbatim)
  • agent_text, agent_thinking, agent_tool, agent_usage (pi)
  • refusal, note

Replay

cargo run --features debug bench replay <run dir>/audit.jsonl [--record <dir>]

Replay feeds the audit’s channel_out lines to a fresh game under the recorded launch target and RNG seed, including any New Game menu actions, and compares where the world ended with the audit’s last view. New Game replay also compares the authoritative world seed. Two plays of one seed are not byte-identical once rounds fly: how many land drifts between processes. The verdict has three grades:

VerdictMeaningExit
replay_matchbyte for byte0
replay_closesame tick, outcome and ships down; positions and the health of the ships still standing within 5 % or 10 m0
replay_mismatchanything else1

With --record the replay is the agent’s run as a movie, stitched the same way a play’s is.

The movie

--record <dir> on a play or replay passes the directory to the game’s channel recorder. The offscreen assembly uses the render stack without an OS window. It routes cameras and UI picking to one image; the shared nova_autopilot LoopRecorder requests that image in Last for each stepped tick. The directory must be empty (or absent): recording refuses to overwrite prior contents. Frame zero is the state after tick one. The recorder keeps <dir>/frame_%06d.png, starting at frame_000000.png, and writes <dir>/bench.jsonl with per-frame sampled VoiceMix SFX, copied sound samples, <dir>/audio.f32le (staged stereo PCM, retained), and <dir>/bench.webm with Opus SFX. The SFX mix is independent of live --mute output.

The channel’s step clock advances by 16,667 microseconds per tick; video playback is nominally 60 fps, one frame per tick, regardless of how long the agent thinks between acts. After the game exits, the bench draws the run’s action rail over those PNGs and encodes <dir>.mp4, muxing the recording’s audio. The bench prints the movie path, frame count and length after the score table and notes them in the audit. If the face is unavailable, the bench makes a plain movie with the same audio. An older bare-PNG directory with no bench.jsonl or bench.webm can still be made into a silent movie with bench movie. A new recording with missing expected audio, sample bytes, frames, failed capture, or failed encoding/muxing is an error, not a silent success; ffmpeg is required for recording and movie creation.

Recording needs a GPU and a display. Run the bench from the dev shell (nix develop), which is where the Vulkan loader finds the driver, on an X display: your own, or an Xvfb (DISPLAY=:99). A play without --record needs neither. Inspect the recorded frames and listen to the movie before claiming the rendered picture or sound is correct.

The action rail

cargo run --features debug bench movie <run dir>/audit.jsonl --frames <dir>

bench movie remakes the movie of frames already on disk, so the overlay can be changed without replaying anything. --out names the file (default <frames>.mp4), --font the face, and --plain leaves the frames untouched by the rail. Both paths mux bench.webm audio when present; if bench.jsonl says a new recording exists but its audio is missing, they fail.

The rail is composited in the bench, not in an ffmpeg filter graph: image decodes the PNGs, ab_glyph sets the game’s own terminal face over them, and ffmpeg is handed finished RGB frames on stdin. Layout that lived in a filter string could not be read or tested.

A header strip carries the identity of the run - scenario, agent, seed, clock, tick and turn - so a clip lifted out of a movie still says where it came from. Under it a transcript of the last six lines, newest at the bottom, older ones fading:

> act    aim camera.camera_rotate [-80, 0] x21
> say    EXIT is 3.3 degrees off the nose at 7.27 km. With weapons lowered,
         I'll hold radar to acquire it as a travel lock.
> act    press targeting.radar_hold
> think  Planning lock release and navigation

One lane per kind of line, each its own colour: goal (once, at the head), think, say, look (observe and page, which spend no tick), act, nova for a command shell line, cheat, and err in red for a refusal.

The cheat lane is the world’s word, not the shape of a command. The first snapshot that carries cheats_marked puts CHEATS ARMED: this run is marked on the rail in amber, and from that frame on the header says CHEATED and turns amber too. A clip cut from a marked run cannot be read as clean, and a cheat the game does not mark does not colour the rail - which is the point of the “cheat without getting marked” red-team goal.

Placement is arithmetic, not a guess. The recorder captures one frame per stepped tick and numbers from zero, so frame = tick - 1. channel_in carries the tick; agent prose does not, because the world is frozen while the model thinks, so it lands on the tick of the snapshot it was answering. Rows that would share one frame are spread 21 frames apart - a first turn can produce ten rows, and all ten on frame zero is a wall of text. That spread is why the opening rows can trail the world by a second or two; the rail is a log of the run, not a caption track.

A short run can talk for longer than it flies. When the spread rows would run past the last frame, the gap tightens until they fit, and if even a one-frame gap is too tight the movie holds the last frame the game drew - up to 30 s - while the remaining rows scroll past. The header clock freezes with the picture, so a held tail never claims ticks the run did not take.

Measured on the hunt fixture, seed 7

AgentOutcomeTicksTurnsAmmoDamageWallCost
baselinevictory66122363005.4 s0
pi gpt-5.6-luna, low thinkingvictory48152940033 s$0.005
pi gpt-5.6-luna, low thinkingvictory9711241286666 s$0.012
pi gpt-5.6-luna, low thinking, --recordvictory1241113416413114 s$0.010

Three pi plays of one seed, one row each; the third was recorded and stitched into a 20.7 s movie at 1280x720. The first play’s opening act was malformed (two verbs in one gesture); the relay returned the parse error as a tool error and the model corrected itself on the next call. Both audits replay close.

Measured on the range fixture, seed 7

The range has no objective, so the goal comes from --goal and the reader grades it from the end row. All plays --ticks 12000 --deadline 900; the pi rows are gpt-5.6-luna with low thinking.

GoalAgentEnded byTicksTurnsAmmoDamageWallCostEnd row
orbit the planetoidpi, first manualagent exit2041100750870 s$0.012helm manual, well planetoid, body planetoid 7 m
orbit the planetoidpi, manual with the speed bandfinish, done164670041 s$0.007helm Orbit planetoid (Hold), body planetoid 2123 m
park on the derelictpifinish, done45812100115 s$0.024helm manual, contact derelict 172 m
destroy the derelictcmd: probefinish, 1 kill3371343000025 s0no contact left
destroy the derelictpi, first manualfinish, gave up109032151362748143 s$0.029helm manual, contact derelict 5787 m
destroy the derelictpi, manual with range controlfinish, done, 1 kill2626111096065 s$0.011no contact left

The first orbit play approached at 572 m/s, tapped the orbit helm as the well appeared and dove into the surface in Burn; pi stopped talking with the bridge and the main drive gone. The manual now gives the speed band (60 ticks of drive is 80 m/s, 240 is 320, the helm takes a ring from 320 but not from 550) and the same model held a ring at 2.1 km on the next play. The first destroy play locked the derelict in 100 ticks, then burned to 240 m/s, tapped Stop at 2 km, overshot at 157 m, kept the guns on it to 4578 hp while it receded, and burned to 1650 m/s trying to get back. The scripted probe closes at 80 m/s, stops at 1.6 km and holds the triggers: the hauler breaks up and leaves contacts after 56 game seconds. With the fight steps rewritten around range control, pi closed at 160 m/s, tapped Stop at 1.96 km, settled 1.1 km off and broke the hauler up in 900 ticks of fire.

Not built yet

A --ui tui renderer on the same event bus, and bench run, a sweep of plays over a scenario set with an index.json, an HTML report and a --baseline diff release over release.

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 nothing. Also makes the settings store INERTharness
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 record. Also makes the settings store INERTharness
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.

Either of the two also silences the run and pins its settings. SettingsStorePlugin::from_env reads the same pair the mute does (HARNESS_ENVS, through nova_gameplay::harness_env_active) and goes INERT: no load at startup, no save on change, no window mode applied. So a scripted run flies on the DEFAULTS whatever the launching player has saved - which is what makes a capture the same picture on every machine - and cannot write that player’s settings.ron. A wasm build has no environment to read, so an app that must be inert there (nova_perf_web) adds the plugin itself with access: SettingsStoreAccess::Inert.

Inert is the far end of a three-way access, not a flag. A normal app gets Read: it boots on the player’s saved settings and cannot write them back. NovaMenuPlugin raises that to ReadWrite when it builds the settings panel, which is the only surface that asks for a value to be kept. An inert store is never raised, so a scripted run that happens to build a menu still writes nothing.

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 agent bench: an agent at the channel

Owned by nova_bench. The mechanism is The agent bench.

VariableGatesFor
NOVA_BENCH_SOCKETthe unix socket the referee answers on; bench play sets it for the agent process, and a cmd: agent reads itharness
NOVA_BENCH_PIthe pi binary bench play --agent pi spawns; unset, pi on the pathharness

The bench sets NOVA_SEED on the child game from --seed, and points the child at an empty profile under the run directory, so a play never reads or writes the settings on disk.

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.

It is the store root for a whole PROCESS, so it is a tooling lever, not a test one: a test that repoints it repoints every test running beside it. A test names its own root on SettingsStorePlugin instead.

The menu

VariableGatesOwnerFor
NOVA_MENU_BACKDROPpins the first backdrop of each menu entry to one role: Backdrop scenario id; the rotation continues from there; an unknown id warns and falls back to the drawnova_menuharness

Not on the roster

  • Example-local knobs. NOVA_STRESS_PD_*, NOVA_EDITOR_FRAMELOG, NOVA_SOAK_SCENARIO, NOVA_SOAK_SECS, NOVA_VFX_RANGE_BARE_SLUG, NOVA_RAILGUN_AFTERMATH, NOVA_RAILGUN_LIVE, NOVA_WAKE_LOOP, NOVA_COLLAPSE_LOOP, NOVA_SIGHT_LOOP and NOVA_STANDOFF_TRACE belong to one example each and stay literals beside the range that reads them. The two railgun names pick which of the shipped loops screenshot_railgun records - the aftermath cut, and the real-speed pass against the default slowed clock - and scripts/capture-web-media.sh sets them. Neither goes wrong quietly. NOVA_RAILGUN_AFTERMATH is a duration: unset it falls back to the producer’s own window, and a value that is not a positive number of seconds panics. A row that forgets NOVA_RAILGUN_LIVE=1 records the SLOWED loop name, so the live row’s file never appears - and the script rm -fs that file before the run and hard-checks it after, so the capture aborts naming the missing loop, having overwritten the already-staged slowed row on the way.

    The three *_LOOP names put one range each on its RECORDING path instead of its reading one, and scripts/capture-web-media.sh sets them. NOVA_WAKE_LOOP=1 records railgun_wake_bench’s close pass as news-0130-railgun-wake instead of walking the stills. NOVA_COLLAPSE_LOOP=1 records stress_hull_collapse’s collapse as news-0130-hull-collapse; the range still checks its invariants, but the loop pins the clock, so that run’s millisecond claims are the recorder’s and not the collapse’s. NOVA_SIGHT_LOOP=1 records system_lock_line_of_sight’s drifting cover as news-0130-lock-occlusion and runs no assertions at all. Each takes 0 or 1, and any other value is an authoring error that panics rather than picking a path.

    NOVA_STANDOFF_TRACE is a diagnostic and not a mode: set to anything, it adds loop_goto_standoff’s per-leg print - the plan the computer publishes beside what the hull does, five times a second of world time - and changes nothing the producer records.

  • NOVA_OS_*. Around 180 of these exist and NONE is an environment variable: they are const Color, layout and volume values in nova_interface, nova_command 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. No Rust reads any of them.

    The media packagers own two more. NOVA_UNFREEZE takes a comma-separated prefix list and re-opens the frozen news- names of the post being authored (NOVA_UNFREEZE=news-0130), in scripts/gen-web-screenshots.py for stills and scripts/capture-web-media.sh for loops; without it a name a post already ships is never overwritten. NOVA_REUSE_STAGE=1 tells scripts/capture-web-media.sh to package a completed target/loop-shots set rather than re-run the producers, which is the repackage path after a capture that finished.

  • Retired. NOVA_MENU_PATH is read by nothing. examples/ui/editor.rs owns the create-a-ship-and-Play sequence end to end, so system_menu_boot dropped the branch; the only surviving mention is that example’s module doc saying the branch is gone.

  • 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 compute pool is capped at eight workers

AppBuilder::assemble sets Bevy’s TaskPoolPlugin with compute.max_threads = nova_core::MAX_COMPUTE_WORKERS, which is 8.

Bevy’s default hands the compute pool every core left after the IO and async-compute pools take four each, so it is total - 8 on any host with enough cores to fill them - 16 workers on a 24-thread desktop. The cap binds only above 16 logical CPUs: a 16-thread host already lands on eight, and a smaller one lands under it. On wasm the pool is single-threaded and the number is ignored.

The extra workers were not doing extra work. bevy_transform’s propagation workers share one queue behind a spin retry (bevy_transform-0.19.0/src/systems.rs:574-610), and in a measured 4v4 wfc_arena fight the sampled spin was 23-24% of all CPU at 16 workers against 8-9% at eight. Five release repeats per arm, same binary, same seed: the mean frame time went 18.69 ms -> 13.41 and p99 41.18 ms -> 27.39, with the per-repeat ranges disjoint on both. stress_bullets and stress_point_defense moved the same way. Four workers removed still more spin and used less total CPU but was about 4.9% SLOWER than eight on the mean, and the two p99 ranges overlap - minimising CPU work is not minimising frame latency.

Eight is a measurement on one hybrid i9, not a proven optimum for every CPU. Re-measure before moving it.

Measure at the shipped cap, not above it

A worker count is a contention regime, and the regime is an amplifier. The same pair of revisions measured across the same code change read +33.5% at 16 workers and +10.2% at eight: the busier pool turns a small workload increase into a large frame-time increase. A bisection run at the old 16-worker default therefore over-attributes cost to whichever commit added work, and its boundaries are not comparable with a capped run’s. Say which regime a number came from, and prefer the shipped cap.

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. The behavioral (clean) pass has no window to size against, so it sizes from --timeout instead - 30 s under it, so the named-laggards line still beats the supervisor’s kill - which is what makes --timeout move the budget of a long correctness range. 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 the BUILD PROFILE) so a results file names its own renderer. That metadata is required, not optional: the reader accepts one header only, and a results file from an older schema is refused with unexpected CSV header rather than loaded with values it never carried. 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.

Streamed-world fill and steady windows

world_clusters probes a 125-cell, content-rich streaming window. Its default capture waits for all 125 cells to be live and planned, warms up for 60 frames, then records 300 steady frames without camera travel. Set NOVA_WORLD_FILL_PROBE=1 for a separate 40-frame capture starting at 60 live cells. These windows do not have the same workload: check live root counts, fixed-step counts and admitted repeats before comparing them. The fill window can advance different numbers of cells in the same 40 frames. Per-cell nova_world::prepare_sector trace spans separate worker generation, validation, rock geometry and planet preparation; deferred command application and Avian physics appear in the existing system spans. Profiled spans rank causes but cannot be substituted for the clean capture’s frame times.

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_interface, 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
        )
    }
    }

    Any distance, speed or acceleration on <Kind>SectionConfig is typed Meters, MetersPerSecond or MetersPerSecondSquared (nova_events::units) and never a bare f32 - a reach, a blast radius, a muzzle speed. The type is what documents the unit for the RON author and what the editor stamps the row’s m / m/s / m/s2 from, and to_engine() where the value reaches Bevy or avian is the one crossing. What stays a bare f32 is what the engine owns: a collider extent or a build-grid offset, which are cells. See Units and scale.

    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 interface’s Ship pane labels sections by this enum: add an arm to the exhaustive matches code_prefix, kind_description and kind_index in crates/nova_interface/src/ship/sections.rs, and to SectionIconType::of in crates/nova_interface/src/icons.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 handling only if your kind needs it (see the Turret / Thruster arms for that pattern):

    #![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. Add a family module under crates/nova_authoring/src/base_content/sections/ (or extend the one this kind belongs to) and return your SectionConfig from its 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; the interface’s label matches - crates/nova_interface/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: section_catalog - crates/nova_authoring/src/base_content/sections/mod.rs, with one prototypes() per family module beside it.
  • 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.

The example below is a kind that does NOT ship, so it stays a recipe. The closest shipped pair to read beside it is the docking lifecycle: OnDocked / OnUndocked in nova_events/src/lib.rs, their table rows in events.rs, and track_docking_transitions in loader/trackers.rs.

  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("onscancomplete")]
    #[event_info(OnScanCompleteEventInfo)]
    pub struct OnScanCompleteEvent;
    
    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, Reflect)]
    pub struct OnScanCompleteEventInfo {
        #[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 a ROW to the scenario_events! table near the bottom of the file. The enum, the From<EventConfig> for EventHandler<NovaEventWorld> arm, ALL, COUNT, name and label are all generated from it - do not hand-edit any of them, and do not edit the macro body above the table:

    #![allow(unused)]
    fn main() {
    scenario_events! {
        // ...
        /// Fires once a ship finishes scanning an object (`id` = the object,
        /// other = the scanning ship).
        OnScanComplete => OnScanCompleteEvent { label: "On Scan Complete" },
    }
    }

    The doc comment on the row is the one a reader gets; the label is the row the editor’s trigger menu lists it under.

  3. Fire it. Engine-driven events fire from crates/nova_scenario/src/loader/ with commands.fire::<OnScanCompleteEvent>(OnScanCompleteEventInfo { .. }) (see the OnStart site in loader/lifecycle.rs, OnUpdate in loader/clock.rs, and orbit-lifecycle/lock/docking 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 TABLE in actions/mod.rs, the macro that reads it in actions/registry.rs, and each action’s config and impl in the submodule for what it touches (audio.rs, cinematic.rs, flow.rs, mission.rs, sequence.rs, ship.rs, spawn.rs, timer.rs, view.rs).

EventActionConfig is GENERATED. scenario_actions! turns one table row into the enum arm, the dispatch, the creative-map class, the reflected payload accessor and the whole authoring-menu surface. Four things it cannot generate are yours: the payload struct, the editor’s stock value, the lint arm if the action names anything, and the docs.

  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 a ROW to the scenario_actions! table in actions/mod.rs. Rows are in the order an authoring menu lists them - mission surface, then the world, then the ships in it, then the run’s own flow, authoring aids last - and that order IS ActionTag::ALL, so put the row where the action belongs in the menu:

    #![allow(unused)]
    fn main() {
    registry::scenario_actions! {
        // ...
        /// Clear a scenario variable back to false.
        VariableClear(VariableClearActionConfig) {
            label: "Variable Clear",
            stem: "clear",
            effect: Bookkeeping,
            inspect: Reflect,
        },
    }
    }

    The two fields worth thinking about:

    • effect is the creative-map class. Bookkeeping touches only scenario state and presentation; Injection reaches into the simulation in a way playing could not have produced, and the badge reports it by name; Nested means the class is whatever the actions it schedules are worth.
    • inspect is Reflect for every action but one: the editor draws the payload’s fields from the reflection. Opaque is for a payload the editor lifts into its own tree and draws itself, which today is only a beat chain.

    If the action NESTS other actions, it also needs an arm in EventActionConfig::step_chain and step_chain_mut (actions/mod.rs). That is the one place a nesting arm is declared, and every walker in the tree - the lint, the frame grouping, walk, walk_mut - reads it. Adding the arm is the whole job; missing it is a bug that only shows up in the walker nobody thought about.

  3. Export the config struct from the actions/mod.rs prelude block.

  4. Make it authorable in the editor: derive Reflect and tag the naming strings, as in recipe 2. The editor’s list is not its own - ActionChoice is a type alias for the scenario crate’s ActionTag (crates/nova_editor/src/event.rs), so ALL, label and stem come off the table row you just wrote and there is no second list to keep in step. What the editor still owes you is one arm in ActionChoiceExt::stock: the value a freshly added action starts life as, with every id field EMPTY. The match is exhaustive, so the compiler asks for it.

    A leaf action needs nothing else from the tree. An action the editor holds as more than a leaf - because its steps are child NODES, or because its panel shows a head rather than the whole payload - is an ActionKind variant of its own, and owes arms in action_config, action_config_mut and action_choice beside it. Sequence and Cinematic are there because their steps are children; VariableSet because its panel is a head. All three matches are exhaustive.

  5. Lint it, if it names anything. An action that carries an id or a file reference needs an arm in crates/nova_scenario/src/lint/ so an unresolvable name is an error at lint rather than a surprise at load. This is the fourth thing the table cannot generate.

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 - three of the six shipped kinds are static). Model it on crates/nova_scenario/src/objects/beacon.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_interface’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; three of the six kinds are static).

    Every distance, speed or acceleration the config carries is Meters, MetersPerSecond, MetersPerSecondSquared, or MetersPerSecond3 (nova_events::units), never a bare f32: the TYPE is what says what the number means, it is what the editor stamps a row’s unit from, and to_engine() at the spawn site is the one place it becomes a Bevy or avian number. Vector positions are Meters3; vector velocities are MetersPerSecond3. Mobile asteroids and all ship roots are gravity-affected; planets are static gravity wells with required mass. The exception, and the only one, is a value the engine owns outright - a build-grid cell, a collider extent. See Units and scale.

    An authored field NAMES its value. A config that carries a String id has one table that says which ids exist, and an id that table does not know is an error at the earliest point that can see it - a content lint error first, a loud refusal on the load path second. It is never resolved to a house answer. An Option is right only where absence is a documented OVERRIDE with runtime-derived behavior behind it, and the field’s own doc comment says what the absence MEANS. lock_signature (absent = the radius) is one shipped precedent. PlanetConfig::mass is required because every planet is a static gravity well. There is no AsteroidConfig::mass field: mobile asteroids and ship roots respond to gravity rather than authoring wells. AsteroidConfig::kind is a plain required AsteroidKindId, because shading has no runtime answer to fall back on.

    #![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 {
        /// Trigger radius.
        pub radius: Meters,
        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),
        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, -2000.0),
        rotation: (0.0, 0.0, 0.0, 1.0),
    ),
    kind: Mine(MineConfig(radius: 50.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 generated from the scenario_events! table, 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 a stock arm. The choice list itself is the scenario crate’s ActionTag, so there is no variant to add - just the value a new action starts as.
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 table has its row (and, for a nesting action, its step_chain arm); the type is exported from its module prelude; the editor has its stock value (events excepted); the lint refuses a name that cannot resolve; 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: the scenario_events! table - 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: the scenario_actions! table - crates/nova_scenario/src/actions/mod.rs; the macro that reads it - actions/registry.rs (submodules: audio, cinematic, flow, mission, sequence, 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, and ActionChoice (an alias for nova_scenario’s ActionTag) with ActionChoiceExt::stock - 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 in 2026-08 against examples/playable/shared/wfc.rs, where the collapse used to live as consts in one example file. It lives in crates/nova_wfc now and every number the note treated as a constant is a field of a WfcPlan (nova_wfc::prelude::WfcPlan), so the citations below have been re-derived. The REASONING is unchanged and is why the note is kept.

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.

Three of the six recommendations below have landed since, and a fourth thing arrived that was not on the list. Each item carries its own status line.

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.

Every row was a constant when this was written. All but two are WfcPlan fields now, which is the change that made the note need re-deriving rather than retiring: the facts are the same facts, each caller can retune them, and WfcPlan::standard_hull is the plan the numbers below come from. The plan is code and not content - which generator wants which section is generator policy, so a section stays a description of geometry and capability.

FactWhereWhat it already means
grid.length = 11 on zWfcGrid, authoredthe ship’s long axis
grid.height = 5 on y; the keel row is height / 2WfcGrid; TileSet::hullup, and a middle
x = 0 is the mirror planeGrid::starboard_half starts at x = 0.5port/starboard
vacuum.bow_taper = 24.0WfcVacuum, read by Plan::vacuum_weightz = 0 is the BOW
vacuum.stern = 9.0WfcVacuum, same readerthe last row is sparse
seed_keelcollapses the spine by hand, from keel.hulla connected structure to grow on
keel.bridge at length / 3the 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 the spine before the generator gets a say, and already puts keel.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| (collapse.rs, Plan::vacuum_weight), 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, as ranked at the time. Where they stand now:

#ThenNow
1. Per-part aimdoneshipped, as WfcPart::aim
2. Seed the drive deckdoneshipped, and it seeds a multi-cell drive whole
3. Deck and bellymeasured, not landedunchanged: measured, not landed, still a taste call
4. Seed a superstructureproposalnot landed
5. Zone the grid“only if 1-4 are not enough”the MECHANISM shipped as WfcPart::zone; standard_hull names none
6. Re-tune the weightslastnot done; the weights are plan fields, so each caller retunes its own

Not on the list and shipped anyway: a seeded SPINAL GUN (WfcKeel::bow_gun), which is item 2’s move applied to the bow. It is what replaced the wfc_arena bench stamping a lance on after the collapse.

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

Landed as proposed, then moved into the plan: a draw entry carries aim: Option<WfcAim> - the only face this part may fire through - and Plan::domains strikes any tile Plan::aim_allowed disagrees with. The shipped plan aims its thrusters Aft; everything else names nothing.

  • Cost: one struct field, a four-line predicate, one line in Plan::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. SHIPPED

seed_stern hand-collapses the stern before the roll: a deck plate the size of the drive’s mount face, and the drive bolted to its aft face. The mirror makes that a pair either side of the centreline. seed_keel 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.

It grew past the two cells proposed here: the drive is named by the plan (WfcKeel::stern_drive) and may be several cells on a side, so the seed lays the block by its minimum corner and propagation fills the rest of it in. runnable refuses a grid too small to hold what the plan seeds, by name, rather than letting the seed run off the end.

  • 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

It would be two more WfcVacuum fields today, not two consts.

off_keel is symmetric in y, so a ship has no top. Weight the two directions differently - |y - keel_row| times a deck taper above and a 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. NOT LANDED

seed_keel puts keel.bridge at the 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 was real: a seeded tower is a stud, and erode_studs exists to take those off. run now marks every cell the seed passes write, widens the mark over a part’s joints, and erode_studs skips it - so a tower seeded the way the stern is inherits THAT exemption rather than needing its own. It inherits nothing else: keel_component and erode_blocked_exits drop parts through the same drop_part and never read the mark. The stern deck survives the keel component only because it sits beside the keel, and both seeded lanes exit the grid; a tower at y = HEIGHT - 1 reaches the keel through cells the roll placed, so it needs its own answer there.

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

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.

Shipped as WfcPart::zone, with six regions rather than three bands: thirds along the hull (Bow, Amidships, Stern), halves either side of the keel row (Dorsal, Ventral, and the keel row itself is neither), and the outboard half (Flank). It rules on one CELL, so a multi-cell part is zoned only where every cell of it qualifies. standard_hull authors no zone at all - the mechanism is there for a caller that wants it, and the caution below is why the shipped plan has not spent it.

The caution turned out to be right in a way not anticipated here: a zone CAN empty a domain, which nothing else in this note can. Not through the unary filter itself - vacuum survives that - but across a JOINT, where vacuum is not an option and only the partner segment fits. Zone a multi-cell part into a region too short to hold it and the cell past the boundary has nothing left. The collapse refuses that plan by name; it does not photograph it.

  • 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. NOT DONE

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.
  • The weights are PLAN fields now (WfcPart::weight, WfcVacuum), so a sweep tunes standard_hull rather than the generator, and a caller that wants a different row builds a different plan from it.

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. The WfcPlan is where that fact went instead - code beside the collapse, built fresh by each caller, and read by nothing else.

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 unary filter above is safe on its own, because vacuum is compatible with everything and is never struck. 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 - and note that item 5 already costs a little of it, because a joint face has no vacuum option. The answer there was to REFUSE the plan with a line naming what to change, not to start retrying seeds.

Sources

Verified from the primary source: