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:
- Building and running - toolchain, everyday commands, examples, the web build, releases.
- Project tour - the crate map and where to change X.
- 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 aprelude; 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.
| Concept | Crate(s) | Key files | Entry symbol | Orientation |
|---|---|---|---|---|
| App boot, app assembly, plugin order | nova_core | crates/nova_core/src/lib.rs | AppBuilder | build() wires the whole plugin stack; game binary + CLI flags in src/main.rs. |
| Game states, pause | nova_gameplay, nova_menu | crates/nova_gameplay/src/lib.rs, crates/nova_menu/src/pause.rs | GameStates, PauseStates | State enums + GameMode live in gameplay; ESC overlay, toggle_pause and the clock freeze in menu. |
| Frame flow: Update vs FixedUpdate | nova_ship, nova_gameplay | crates/nova_ship/src/lib.rs, crates/nova_gameplay/src/plugin.rs | SpaceshipSystems | NovaShipPlugin 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 seam | nova_events, nova_ui | crates/nova_events/src/units.rs, crates/nova_events/src/scale.rs, crates/nova_ui/src/units.rs | Meters, METERS_PER_UNIT | Everything 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 merge | nova_assets | crates/nova_assets/src/plugin.rs, crates/nova_assets/src/merge.rs | GameAssetsPlugin | GameAssetsStates gates entry to the menu/game; register_bundles merges base + enabled mods into the Game* resources. |
The ship
Depth: Ship sections internals.
| Concept | Crate(s) | Key files | Entry symbol | Orientation |
|---|---|---|---|---|
| Ship building from sections | nova_scenario, nova_ship | crates/nova_scenario/src/objects/spaceship.rs, crates/nova_ship/src/sections/base_section.rs | insert_spaceship_sections | Observer on the ship root spawns one child per SectionKind config, resolving prototypes; SpaceshipConfig is the authored shape. |
| Section integrity: damage, disable, destroy | nova_gameplay | crates/nova_gameplay/src/integrity/mod.rs, crates/nova_gameplay/src/damage.rs | NovaIntegrityPlugin | Health 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 looks | nova_gameplay | crates/nova_gameplay/src/integrity/erosion.rs | DamageLevel | One 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_gameplay | crates/nova_gameplay/src/integrity/carve.rs | DamageMarks, mark_radius | Spheres 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 looks | nova_ship | crates/nova_ship/src/sections/damage_effects.rs, damage_cracks.rs, damage_sparks.rs, damage_plume.rs | DamageEffects, fit_damage_effects | Cracks/Sparks/Plume, one component each, default [Cracks]. No ship section ever loses geometry. |
| Carve debris: dust and severed pieces | nova_gameplay | crates/nova_gameplay/src/integrity/spew.rs, crates/nova_gameplay/src/integrity/chunk.rs | CarveSpew, ShardLook, spawn_carved_chunk | Shards 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 collapse | nova_ship | crates/nova_ship/src/sections/integrity.rs | ShipIntegrityPlugin | Builds the section graph, splits disconnected structure (sever_disconnected_structures -> ShipWreckFragmentMarker), runs cascade_structural_collapse. |
| Neutralization (combat-dead) | nova_gameplay | crates/nova_gameplay/src/integrity/neutralize.rs | NeutralizedMarker | An 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 plan | nova_wfc | crates/nova_wfc/src/lib.rs, crates/nova_wfc/src/collapse.rs, crates/nova_wfc/src/tiles.rs, crates/nova_wfc/src/plan.rs | TileSet, WfcPlan | TileSet::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, greebles | nova_ship | crates/nova_ship/src/sections/shell_skin.rs, crates/nova_ship/src/sections/skin_style.rs, crates/nova_ship/src/sections/skin_decor.rs | ShipSkinPlugin | Cladding 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, aiming | nova_ship | crates/nova_ship/src/sections/turret_section/mod.rs, crates/nova_ship/src/sections/turret_section/aim.rs | TurretSectionPlugin | Authored joint tree; lead-intercept aim (update_turret_aim_point, muzzle_on_target); arcs and firing in sibling files. |
| Torpedoes, point defense | nova_ship | crates/nova_ship/src/sections/torpedo_section/mod.rs, crates/nova_ship/src/input/point_defense/mod.rs | TorpedoSectionPlugin, SpaceshipPointDefensePlugin | Bay + guided round lifecycle (TorpedoGuidance, TorpedoBlast); PD splits target assignment from mount authority (borrowed player mounts). |
| The spinal lance: commit, charge, shot, recoil | nova_ship | crates/nova_ship/src/sections/railgun_section/mod.rs, crates/nova_ship/src/sections/railgun_section/firing.rs | RailgunSectionPlugin, RailgunCharge | The 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 cut | nova_gameplay, nova_ship | crates/nova_gameplay/src/rounds.rs, crates/nova_ship/src/sections/railgun_section/firing.rs | RoundRake, sweep_raking | Authored 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 break | nova_ship | crates/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.rs | DockingPorts, DockAdmission, DockingConnection, DockingSystems::Release | DOCK 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 sight | nova_hud | crates/nova_hud/src/bore_sight.rs | BoreSightPlugin | The 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 controller | nova_ship | crates/nova_ship/src/flight/state.rs, crates/nova_ship/src/sections/controller_section.rs, crates/nova_ship/src/physics/pd_controller.rs | ShipCapabilities, Autopilot | The 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, targeting | nova_ship | crates/nova_ship/src/input/targeting/mod.rs, crates/nova_ship/src/input/targeting/state.rs | SpaceshipTargetingPlugin | Radar search writes the two sticky lock slots on the ship root: TravelLock, CombatLock. |
| Gravity wells | nova_gameplay | crates/nova_gameplay/src/gravity.rs | GravityWell | Inverse-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.
| Concept | Crate(s) | Key files | Entry symbol | Orientation |
|---|---|---|---|---|
| Scenario engine, mission scripting | nova_scenario, nova_events | crates/nova_scenario/src/lib.rs, crates/nova_events/src/engine.rs | NovaScenarioPlugin | A scenario is handlers = event + filters + actions over NovaEventWorld; the generic queue/dispatch (EventHandler) is nova_events. |
| Scenario events, filters, actions (modding events) | nova_scenario | crates/nova_scenario/src/events.rs, crates/nova_scenario/src/filters.rs, crates/nova_scenario/src/actions/mod.rs | EventConfig, EventFilterConfig, EventActionConfig | One dispatch enum each; actions fan out to the flow/mission/sequence/ship/spawn/timer/view submodules beside mod.rs. |
| Scenario variables, expressions, watches | nova_scenario | crates/nova_scenario/src/variables.rs, crates/nova_scenario/src/world.rs | VariableExpressionNode, NovaEventWorld | Typed literals + expression tree (VariableConditionNode for filters); watches sample typed world queries into variables. |
| Scenario objects (asteroid, planet, spaceship, beacon, crate, light, anchor) | nova_scenario | crates/nova_scenario/src/objects/mod.rs, crates/nova_scenario/src/actions/spawn.rs | ScenarioObjectsPlugin, ScenarioObjectKind | One module per kind under objects/; the spawn action dispatches on the kind enum. |
| Scenario loading, lifetime scoping, teardown | nova_scenario | crates/nova_scenario/src/loader/mod.rs, crates/nova_scenario/src/loader/lifecycle.rs | ScenarioLoaderPlugin | LoadScenario/UnloadScenario observers; everything tagged ScenarioScopedMarker dies at teardown; scenario_is_live gates the ship sets. |
| Mod formats, bundles, portal | nova_mod_format, nova_modding, nova_assets | crates/nova_mod_format/src/lib.rs, crates/nova_modding/src/lib.rs, crates/nova_assets/src/portal/mod.rs | BundleManifest, Content, PortalPlugin | Engine-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_authoring | crates/nova_authoring/src/cli.rs, crates/nova_authoring/src/generation.rs | cli::main | cargo 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
| Concept | Crate(s) | Key files | Entry symbol | Orientation |
|---|---|---|---|---|
The TAB interface and the : command modal | nova_command, nova_interface | crates/nova_command/src/commands.rs, crates/nova_interface/src/pane.rs, crates/nova_interface/src/lib.rs | InterfacePlugin, InterfaceSystems, CommandsSystems | Pure 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 capture | nova_input, nova_menu | crates/nova_input/src/registry.rs, crates/nova_input/src/poll.rs, crates/nova_menu/src/settings.rs | InputBindings, InputSources | One 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 widgets | nova_hud | crates/nova_hud/src/lib.rs | NovaHudPlugin | One module per instrument (crosshairs, ammo, markers, comms, keybind dock); reads the ship, never drives it. |
| Main menu, menu backdrops | nova_menu | crates/nova_menu/src/lib.rs, crates/nova_menu/src/ambience.rs | NovaMenuPlugin | The 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, progress | nova_training, nova_menu | crates/nova_training/src/catalog.rs, crates/nova_training/src/validate.rs, crates/nova_menu/src/training.rs, crates/nova_menu/src/training_store.rs | TrainingCatalog, TrainingProgress | A 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 roles | nova_scenario | crates/nova_scenario/src/loader/mod.rs, crates/nova_scenario/src/lint/scenario.rs | ScenarioRole | Chapter (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 widgets | nova_ui | crates/nova_ui/src/theme/mod.rs, crates/nova_ui/src/widget/mod.rs | NovaUiPlugin | Moddable UI themes (UiThemeConfig -> ActiveUiTheme) and the widget factories every UI-drawing crate consumes. |
| Ship editor, link points, placement | nova_editor, nova_ship | crates/nova_editor/src/lib.rs, crates/nova_editor/src/snap.rs, crates/nova_ship/src/sections/link_points.rs | NovaEditorPlugin | Sandbox 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.
| Concept | Crate(s) | Key files | Entry symbol | Orientation |
|---|---|---|---|---|
| Automation harness, autopilot scripts, screenshot capture | nova_autopilot, nova_debug | crates/nova_autopilot/src/autopilot.rs, crates/nova_debug/src/harness.rs | AutopilotPlugin | Env-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 claims | nova_probe, nova_probe_cli | crates/nova_probe/src/capabilities/mod.rs, crates/nova_probe/src/contract.rs, crates/nova_probe_cli/src/native.rs | NovaProbePlugin | One 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 window | nova_probe | crates/nova_probe/src/capabilities/frametime.rs, framecost.rs, census.rs | nova_frametime, nova_framecost, nova_census | Wall-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_probe | crates/nova_probe/src/capabilities/snapshot.rs | nova_snapshot, probe_snapshot | One 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 hulls | examples, nova_wfc | examples/playable/wfc_arena.rs, examples/playable/wfc_arena/stamps.rs | arena_tiles | Fields 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_debug | crates/nova_debug/src/lib.rs | DebugPlugin | Compiled only under the debug feature; F11 overlay toggle, F12 screenshot, --norender, --debugdump. |
| Web preview, this book at /dev/ | scripts, web | scripts/serve-web.sh, scripts/preview-web.sh, web/webpack.config.js | serve-web.sh | Live-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 developgives the toolchain, thewasm32-unknown-unknowntarget, all system libs Bevy needs (udev, alsa, vulkan, X11/wayland),trunk, andsccache(see fast worktree builds below). Without Nix, install those yourself. Barecargois not on PATH under Nix: run every cargo/rust command vianix develop --command <cmd>(the commands below assume you are insidenix develop). - Playing it, as opposed to working on it:
nix runbuilds 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 barecargo test: unit tests live in the member crates, so the bare form runs almost nothing and gives false comfort. cargo testtakes ONE filter and one-pper 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, thenNOVA_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):
| build | wall clock | sccache stats |
|---|---|---|
| cold (empty cache) | ~6m45s (405s) | 517 misses / 0 hits |
warm (cargo clean, same source) | ~38s | 517 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 wholenova_debugplugin (inspector, wireframe, overlays) plusbevy/track_location.dev- alias fordebug.trace-bevy/trace+bevy/trace_chromefor span traces; the probe harness builds--features debug,tracewhen 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 throughAppBuilder::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, withrender_worldandgpureading 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>underNOVA_AUTOPILOT, orprobe scenario. A bare--norenderticks 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=1makes everyAppBuilder::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 --norenderHeadless 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_compilationexists to prevent are all invisible headless, andcargo checkdoes 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 insertsHarnessMute(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 saysnova audio: output muted for this runonce at startup.The environment twin is
NOVA_MUTE, which an example reads throughHarnessMute::from_env: set to anything but0it mutes,NOVA_MUTE=0forces 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 (viabevy_mod_debugdump) and exit. It dumps theUpdateschedule (debugdumpincrates/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:
| level | fires | example |
|---|---|---|
trace! | per ITEM - anything that scales with content | one line per spawned object, per section, per widget |
debug! | per OPERATION - one line for the whole batch | scattered 26 of 26 'gauntlet_rock_' object(s) |
info! | a person running the game wants it; should be rare | the probe’s own report lines |
warn!/error! | something is WRONG | a 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.
| Category | Who it is for | What probe does with it | Disqualifies an example |
|---|---|---|---|
playable/ | A HUMAN: somebody loads it and does the thing it demonstrates, through an affordance wired outside the NOVA_AUTOPILOT gate | runtime contract decides; native trace is automatic | its 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 roster | runtime contract decides; native trace is automatic | its 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 baseline | runtime contract decides; native trace is automatic | its 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) andwfc_arena(a match bench for wave-function-collapse hulls, with a lobby, a pause menu, a result board and a--ship TEAM:playerslot that puts you in one of them). Then the benches and galleries, each with its own keys:wfc_ships(Rre-rolls the collapsed row),shape_benchandblock_bench(Lcycles the style,Cstrips the cladding),railgun_wake_bench(the slug’s ionized wake at three speeds,1-3pick 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(everynova_uiwidget factory, live and clickable in both skins),compare_asteroids/compare_planets(the number keys re-dress the focus subject) andasteroid_kinds/planet_types(the whole authored range of each, side by side). Last, the mission-scale pair:first_shift_mapis a fixed belt layout with a route over it - the most populated authored scene there is - andfirst_shift_shipsposes eight shipped hulls in a row. All of them still walk and capture underNOVA_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 - andexamples/systems/README.mdowns 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_gunneryandsystem_torpedo_launch(the weapon ranges, the latter also the PN lead-a-crosser deep-dive), andsystem_blast_penetration(explosive falloff, one section shielding the next and two salvos that cannot use the hole they make in the same tick) andsystem_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) andsystem_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 aScenarioConfigwritten in Rust and loaded withLoadScenario: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) andsystem_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) andsystem_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) andsystem_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_structureandstress_many_structureshold 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_defensealso readsNOVA_STRESS_PD_MOUNTSandNOVA_STRESS_PD_BAYS, so one build can be swept across scales without moving what it asserts.stress_hull_collapseis 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_rectinnova_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_pixelsturn 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_pickernames 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 inexamples/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). Theloop_*producers write only video; recording is not their privilege, though - aloop_start/loop_endpair records from any harnessed example, soscreenshot_railgun,screenshot_editor,wfc_arena,system_torpedo_launch,railgun_wake_bench,stress_hull_collapseandsystem_lock_line_of_sighteach ship a loop beside their stills or their readings. What a run makes is decided byscripts/capture-web-media.sh(loops) andscripts/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 thenews-0130-{sections,belt,death}-after.webmfile it writes.loop_sections_compare,loop_belt_compareandloop_death_compareare 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) andscreenshot_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 toplayable/the day it growsgreeble_catalog’s selection layer.scripts/gen-web-screenshots.py --producersprints 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, andscripts/capture-web-shots.shstops 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
SettingsStorePlugininert, so the run loads nosettings.ron, saves none, and applies no window mode. Both read the same pair throughnova_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=0forces sound through a harness run;NOVA_MUTE=1mutes a normal one, and the game binary’s--muteflag 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::NewGameGameStates::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
Loadedfinds 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)
genserializes the code-built base content into the committedassets/base/**/*.content.ron. The base RON is GENERATED from Rust builders (nova_authoring::generation, backed by privatebase_content) - edit the builder and regenerate, never hand-edit the RON, or thecontent_ron_paritytest goes red.lintruns EVERY content check in one pass (theauditsubcommand was folded in here - balance is a kind of lint):- the identifier + geometry + resource checks the load/publish gates cannot
(dangling
NextScenariotargets, unspawnable filter targets, duplicate ids, scenarios with no terminalOutcome, 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.ronbeside 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_mappingsection bound to a key the always-on flight rig also binds (W/Space/RightTrigger burn, autopilot, …) silently double-drives flight and is flagged (WARN). --targetlints a single mod by directory or in-repo id (webmods/<id>,assets/mods/<id>, orbase);--report <path>writes a per-mod document (Markdown, or HTML for a.htmlpath /--format html) that names, for each finding, the file + element + explanation + suggested fix. Exits non-zero on any ERROR. Thecontent_lint_gate,balance_audit_gateandcontent_report_gatetests run these walks in CI.
- the identifier + geometry + resource checks the load/publish gates cannot
(dangling
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:
| Variable | Read by | Effect |
|---|---|---|
NOVA_UI_PORT | web/webpack.config.js | Fixes the site’s port. |
NOVA_GAME_PORT | scripts/serve-web.sh | Fixes the game’s port (exported as TRUNK_SERVE_PORT). |
NOVA_MODS_PORT | scripts/serve-mods.sh | Fixes the portal’s port. |
GAME_DEV_URL | web/webpack.config.js | Where /play is proxied. Default http://localhost:8080 (Trunk’s own default). |
MODS_DEV_URL | web/webpack.config.js | Where /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 servekeeps serving the first build no matter what you edit.serve-web.shpasses--watchfor 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:
| Class | Meaning |
|---|---|
capturable | A game render a cataloged example can capture. |
manual | Authored art (post-card thumbnails, icons, diagrams) - no automation produces it. |
historical | A 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.versionin rootCargo.toml; crates inherit it. nova_info::APP_VERSIONcomes from theAPP_VERSIONenv var viabuild.rs. A--features debugbuild appends the short commit hash (0.12.0+9da8b055, or+unknownoutside a checkout), so the status bar names the revision. The build script tracks.git/HEADand 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:
| output | what it is |
|---|---|
packages.default | the wrapped game - the one to install |
packages.nova-protocol | the same derivation, under its own name |
packages.nova-protocol-unwrapped | the 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/andcredits/are installed undershare/nova-protocol, andBEVY_ASSET_ROOTpoints 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 onLD_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 inflake.nixrather 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:
- 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. - Bump
workspace.package.versionin rootCargo.toml. - Refresh
Cargo.lock:cargo metadata --format-version 1 >/dev/null. - 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). - Commit exactly those three files:
git add Cargo.toml Cargo.lock CHANGELOG.md && git commit -m "chore(release): vX.Y.Z". git tag vX.Y.Z(CI reads the tag for the release name).git push origin master && git push origin vX.Y.Z.- 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 ...). - 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 thealexjercanaccount; - variable
ITCH_TARGET:alexjercan/nova-protocol; - variable
BUTLER_VERSION: the exact Butler release to install; - variables
BUTLER_SHA256_DARWIN_AMD64andBUTLER_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):
- Write the post at
web/src/news/<version>.md(e.g.0.6.0.md). The page shell (newsPostShellinweb/markdown.js) renders the H1, the<date> // v<version>meta line, and the footer (the Discussions prompt plus theCHANGELOG.mdpointer and “All news” link), so the markdown is just the body: the H1 (# vX.Y.0 - <title>), the lead, the##sections,.figureplaceholder blocks for screenshots to capture later, an optional.video-embedcompanion, a.callout.callout--breakingblock for any format break, and a closing## Point releasessection for the cycle’s patches. Do not add a footer or aCHANGELOG.mdlink yourself - the shell adds them. - Register it in
web/webpack.config.js: add an entry toNEWS_POSTS(newest-first) withslug/version/date/description. The plugin list and thehistoryApiFallbackrewrite both derive fromNEWS_POSTS, so no other wiring is needed. - Add a
.post-cardtoweb/src/news.htmlat 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__phplaceholder namingassets/thumb-news-<version>.png. - 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:
-
Branch off
master. Work items are tracked astasks/markdown (see Task tracking below); check the backlog first. -
Build and format:
cargo check && cargo fmtbefore you commit. Do NOT runcargo testorcargo clippylocally unless asked - they are slow and CI is the source of truth; when you skip them, say so. -
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. -
Open a PR. CI (
.github/workflows/ci.yaml) runs on pull requests and pushes tomaster, 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-exampleprobe run --correctness-onlysmoke under Xvfb/lavapipe plus thenova_autopilotexample test under Xvfb. Three more jobs run in parallel with that one: a default-featurescargo check --workspace --all-targetsunderRUSTFLAGS=-D warnings, a wasm32cargo 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 withdebugoff 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.yamlcalls 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 withworkflow_dispatch. Both runtime lists are named, soscripts/check-probe-suites.py(a step in thecheckjob) 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 pointsCLIPPY_CONF_DIRatci/wasm-clippy/, whoseclippy.tomlbans the std APIs that COMPILE for wasm32 and then panic in the browser - a class of bug nocargo checkon that target can see. Those bans are correct only there: nativelybevy::platform::timere-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 rootclippy.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:
- Project tour – this page: the crate map and where to change X.
- Architecture – the full crate graph, app assembly, state machines and frame flow.
- Building & running – toolchain, cargo commands, examples, the web build, and how to contribute a change.
- 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.
| Crate | Owns |
|---|---|
nova-protocol (root) | src/main.rs clap CLI + entrypoint; src/lib.rs re-exports nova_core. |
nova_core | Wiring only: AppBuilder assembles the whole plugin stack. No gameplay. |
nova_gameplay | The shared gameplay layer under the ship: integrity, damage, gravity, the SFX engine, juice, objectives, mesh/transform rigs, entity markers. Owns GameStates/PauseStates/GameMode. |
nova_ship | The 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_wfc | Generated 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_hud | The flight HUD: one module per widget (crosshairs, target inset, ammo readout, objective markers, comms panel, keybind dock). Reads the ship, never drives it. |
nova_command | The command language and the NOVA COMMANDS terminal model: catalog, parser, prompt state. No bevy UI. |
nova_interface | The 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_console | The 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_scenario | Scenario engine: events, filters, actions, variables, world, loader, objects. |
nova_world | The 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_base | The 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_events | Shared game-event kinds + entity identity components (gameplay <-> scenario). |
nova_events_macros | The EventKind derive behind nova_events’ engine events. Its only consumer is nova_events. |
nova_assets | bevy_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_modding | Bundle/content/catalog asset loaders and the Content routing enum. |
nova_mod_format | Pure 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_editor | The ship editor scene (NovaEditorPlugin), shown in GameMode::Sandbox. |
nova_menu | Main menu + the ESC pause overlay; hands off to Playing. New Game opens the world seed modal first. |
nova_training | The 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_input | The 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_ui | Shared 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_debug | Debug-only plugin (inspector, overlays); compiled under the debug feature. |
nova_info | Exposes APP_VERSION, injected by build.rs. |
nova_autopilot | Scripted automation drivers + the run-completion protocol. Bevy-only, game-agnostic. |
nova_channel | Dev tooling: the process channel. Named inputs, pointer moves, text, keys and command lines in on stdin; world snapshots and acknowledgements out on stdout. |
nova_probe | Dev 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_cli | Dev tool: the host half - spawns runs, grades artifacts, renders reports; the probe run/report CLI. |
nova_bench | Dev 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_web | Dev tool: the wasm app probe run --platform web boots and measures. |
nova_authoring | Offline content pipeline: the Rust builders for built-in scenarios/sections, content -- gen (writes assets/base/**/*.content.ron), content -- lint. |
nova_meta_gen | Binary 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 in | Read |
|---|---|---|
| A ship section behavior | crates/nova_ship/src/sections/ | Ship sections, Add a ship section |
| Damage types / how a round travels | crates/nova_gameplay/src/damage.rs | Ship sections |
| Integrity (disable/destroy) | crates/nova_gameplay/src/integrity/ | Ship sections |
| How a body wears its damage | crates/nova_ship/src/sections/damage_*.rs + crates/nova_gameplay/src/integrity/{erosion,carve}.rs | Ship sections |
| How an asteroid carves, and what it costs | crates/nova_scenario/src/objects/asteroid_carve.rs | Scenario engine |
| Flight / autopilot verbs | crates/nova_ship/src/flight/ | – |
| Player input / AI | crates/nova_ship/src/input/{player,ai}/ | – |
| Radar targeting / lock-on | crates/nova_ship/src/input/targeting/ | – |
| Gravity wells | crates/nova_gameplay/src/gravity.rs | – |
| A unit, or what a field is measured in | crates/nova_events/src/units.rs + crates/nova_events/src/scale.rs | Architecture |
| The HUD (widgets) | crates/nova_hud/src/ | – |
The TAB interface, its panes, and the : command modal | crates/nova_interface/src/ | – |
| The command catalog and parser | crates/nova_command/src/commands.rs | Architecture |
| A scenario event/filter/action | crates/nova_scenario/src/{events.rs,filters.rs,actions/} | Scenario engine, Extend the scenario engine |
| Scenario objects / loading | crates/nova_scenario/src/{objects/,loader/} | Scenario engine |
| What a streamed world cell holds, or when one comes and goes | crates/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 / merge | crates/nova_assets/ + crates/nova_modding/ | Mod files, Publish a mod |
| A built-in scenario or section | crates/nova_authoring/src/ (builders), then content -- gen | Create your first scenario |
| A generated hull, or the plan it is drawn from | crates/nova_wfc/ (plan.rs for the plan, WfcPlan::standard_hull for the shipped one) and each caller’s own plan builder | Concept index, Ship layout sense |
| The ship editor | crates/nova_editor/ | – |
| Shared UI theme / widgets | crates/nova_ui/ | – |
| The web site / wiki | web/ | 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. LeavingPlayingreturns throughLoading: 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 inPlaying.GameAssetsStates { Boot, Loading, Processing, Loaded, Failed }– the asset pipeline that gates entry; onOnEnter(Loaded)the app hands off toMainMenu/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.Failedis 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
| Surface | Where | Audience | Kept in sync when |
|---|---|---|---|
CHANGELOG.md | repo root | everyone (terse, complete) | any user-visible change |
| News | web/src/news/*.md -> /news/ | players + modders | each feature release |
| Player wiki | web/src/wiki/*.md -> /wiki/ | players | player-facing behavior changes |
| Lore encyclopedia | web/src/lore/ -> /lore/ | readers + story authors | approved world facts and published story references change |
| Story library | web/src/comics/ -> /story/ | readers + story authors | navigation, TS page scripts, Python panel art, composition, or publication change; update the comic authoring README and keep drafts out of deployable builds |
| Creator docs | web/src/create/*.md -> /create/ | mod authors | a data format or the modding UX changes |
| Dev book (this book) | docs/ -> /dev/ | contributors | internals, architecture, or the dev workflow change |
| rustdoc | local: cargo doc --open | contributors | with 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”:
- Did user-visible behavior change? Apply the Changelog section of
AGENTS.md; it owns the entry and release-baseline rules. - 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.
- 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 wiki | Dev book / creator docs | Also |
|---|---|---|---|
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 keys | CHANGELOG (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 stated | web/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.md | Ship 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.md | Ship 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.md | Scenario 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.md | Ship 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.md | Architecture, /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.md | Architecture | CHANGELOG |
Gravity wells, factions, world (nova_gameplay) | gravity-wells.md, factions.md | CHANGELOG | |
Scenario engine: events, filters, actions, variables, objects (nova_scenario, nova_events) | scenarios.md | Scenario 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 tour | CHANGELOG (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.md | Concept index (the row under The ship), Ship layout sense | CHANGELOG. 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.md | Architecture (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 reference | CHANGELOG (a lane rename is breaking for every run script) | |
Menus, editor, UI (nova_menu, nova_editor, nova_ui) | hud.md, sections.md | Add a ship section | tutorial, 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 index | CHANGELOG; 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 harness | CHANGELOG (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 one | the 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 table | CHANGELOG (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 anywhere | CHANGELOG (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 procedure | Preserve 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-widgetblock, 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:
-
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. -
News: a feature release (
0.X.0) gets a new post atweb/src/news/<version>.md, registered inNEWS_POSTSinweb/webpack.config.jswith a card added toweb/src/news.html. A patch release (0.X.Y) gets no post of its own - fold it into its parent feature post’s## Point releasessection 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.figureblock that auto-upgrades to its screenshot oncescripts/gen-web-screenshots.pypackages 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 anews-file that already exists (NOVA_UNFREEZE=news-0120re-opens the post being authored), and anews-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 anews-figure is pinned to an old release and can never update.web/tests/assets.test.jsfails the build on either, so authoring a figure means adding an alias inscripts/gen-web-screenshots.py(stills) orscripts/capture-web-media.sh(loops). -
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.
-
This book: same rule - chapters stay current through the cycle, and
nix develop --command mdbook buildmust 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. -
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>.mdand list it indocs/SUMMARY.md.mdbook buildwarns 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 inweb/src/lore/README.md; it is a website section, not a second mdBook. - A news post: edit
NEWS_POSTSinweb/webpack.config.jsplus a card inweb/src/news.html. - Retiring a URL: add a stub to
REDIRECTSinweb/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
| Crate | Responsibility |
|---|---|
nova-protocol (root) | src/main.rs = clap CLI + entrypoint. src/lib.rs re-exports nova_core. Runnable examples in examples/. |
nova_core | Thin wiring only: AppBuilder assembles every plugin (window/log/asset setup, status UI). No gameplay logic. |
nova_menu | Main 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_editor | The ship editor scene (NovaEditorPlugin). Comes up on entering Playing, only in GameMode::Sandbox. |
nova_gameplay | The 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_ship | The 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_wfc | Generated 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_hud | The 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_command | The 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_interface | The 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_console | The 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_scenario | Scenario/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_world | The 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_base | The 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_events | Game 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_macros | Procedural macros behind nova_events’ derives. |
nova_assets | bevy_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_uiis not menu-only. Every crate that renders UI draws on it -nova_gameplay(which addsNovaUiPluginrender-gated),nova_hud(the chip language and screen-indicator styling),nova_interface,nova_menu,nova_editorandnova_assets.nova_scenarioreaches up intonova_shipandnova_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:
- The generic-leaning modules named above - reusable Bevy primitives that happen to live in a nova crate; keep them free of game-specific types.
nova_gameplay- the shared gameplay layer, ship-agnostic.nova_ship- the ship above that layer.nova_hudandnova_interface- consumers of the ship and of gameplay, above both.nova_interfaceis abovenova_hudin turn: it orders itself againstNovaHudSystems.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_armreads 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
muis L^3/T^2, so an SI one would be a THOUSAND times the world-unit one;DAMAGE_PER_UNIT_VOLUMEis 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.MainMenuonly occurs whenNovaMenuPluginfronts the app (the default editor app); examples with custom game plugins go straightLoading -> Playing. TheGameModeresource (Sandboxdefault |NewGame) records what the menu handed off to.- Leaving
Playinggoes back throughLoading, not straight to the menu.nova_menuwritesReloadContentonOnExit(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_loadingrewrites the pendingMainMenuintoLoadingin theStateTransitionschedule, beforeStateTransitionSystems::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 ownExitToMenu- because the redirect is on the transition rather than on the four call sites, andOnExit(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_ambiencetriggersUnloadScenariobefore 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 (noGameAssets) is exempt: it has no restart to come back from. PauseStates { Unpaused, Paused, Interface, Commands }- the freeze axis.Pausedis the ESC pause overlay;Interfaceis the TAB pane (Map, Ship or Inventory,InterfacePaneType, a resource that outlives the state);Commandsis theNOVA COMMANDSCRT modal (cursor freed, no pause menu). The live transitions areUnpaused <-> Paused,Unpaused <-> Interface, andCommandsto and from each ofUnpaused,PausedandInterface: the modal is a surface OVER what was there, soNovaOsCloseTransition::return_torecords the state it covered and closing restores it.nova_gameplayowns the enum and gates the spaceship sets;nova_menuowns the toggles and the pause overlay UI. The two keys are gated differently. Tab needs a player ship and so runsin_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.Escapehas 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):Pausedis 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 whilePausedis 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.FocusPausecarries the policy and boots OFF under a harness (harness_env_active): a probe drives an X display nobody is looking at, and a--norenderrun has no window to lose. An authoredauto_advance_secskeeps 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:
ClockFreezecountsFreezeOwner::{PauseMenu, Interface, ScenarioLoad}and stopsTime<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 thePauseMenuhold beforeOnEnter(Commands)takes theInterfaceone, and the way back re-takes it. Naming the owners is what stops one surface’s release from unfreezing the other’s world.InterfaceandCommandsshare the oneInterfaceowner, andrelease_clocks_for_interfaceskips 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 toPlaying(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
GlobalZIndexfrom 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 isDespawnOnExit(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 underEDITOR_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.
WindowModeSettingdefaults toBorderless, 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 savedWindowedkeeps it;apply_window_modeis registered only for a READING store, so a harnessed run (inert store) never touches the window it was given.window_pluginhands the window managerMIN_WINDOW_WIDTHxMIN_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 atSETTINGS_PANEL_WIDTH_PCTof the window capped atSETTINGS_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.Bootloads the UI font the loading screen itself draws with;Loadingloads the rest of the MANDATORY set (the shared art and the catalog, whose only loaded dependency is the base game’s bundle);Processingkicks the OPTIONAL cataloged mods, waits for them to settle, and merges. Scenario setup hooksOnEnter(GameAssetsStates::Loaded)- seeexamples/systems/system_scenario_grammar.rs.Failedis 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_modedisables that mod, persists the choice, and the front door owes oneMODS DISABLEDreport 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
FixedPostUpdateon a fixed timestep. Rigid bodies getTransformInterpolationso rendering stays smooth between physics ticks. PostUpdatehosts 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. PreUpdatehostsnova_ui’sInputModeSystems, which resolves that frame’sClaimKeyboardmessages into the oneInputModeevery keyboard system then gates on. A claimant writes its claim.before(InputModeSystems); a consumer reads the resolved mode within_input_mode,in_input_mode_at_mostorowns_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 HzFixedUpdatetick. This is the truth the simulation integrates. - Render pose –
Transform, eased between the previous and current physics states, withGlobalTransformpropagated from it inPostUpdate.
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::roundssweeps by hand after the physics step), guidance. It MUST read the rawPosition/Rotation(or compose the root’s raw pose with a local mount offset). DuringFixedUpdateof frame N,GlobalTransformstill holds the eased pose propagated in frame N-1’sPostUpdate, 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 easedTransform/GlobalTransform, and every pose in one on-screen computation must come from the same frame. A consumer ofPostUpdate-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 andCommandChannel-crates/nova_command/src/commands.rs; the dispatcher that runs them against the world -crates/nova_console/src/dispatch.rs; the:gesture -open_command_shellincrates/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 inUpdate+FixedUpdatebyNovaShipPlugin-crates/nova_ship/src/lib.rs. - Who owns the keyboard:
InputMode,ClaimKeyboardandInputModeSystems-crates/nova_ui/src/input_mode.rs; the editor’s claimant -declare_editor_keyboard_ownerincrates/nova_editor/src/lib.rs. - The two damage readings:
DamageLevel-crates/nova_gameplay/src/integrity/erosion.rs;DamageMarksand 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 bothcontent lintand 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 isproven_by-crates/nova_authoring/src/base_content/lessons.rsand.../scenarios/drills.rs. A lesson’swiki_pathpoints into the web manual underweb/src/, andcrates/nova_authoring/tests/lesson_wiki_links.rsfails 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):
| Kind | What it does |
|---|---|
Hull | Passive structure/armor. Just a render_mesh. |
Thruster | Forward thrust (magnitude); drives the exhaust visual. |
Controller | Attitude 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. |
Turret | Aims 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. |
Torpedo | Torpedo 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. |
Railgun | Spinal 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. |
Docking | Docking 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). |
CargoIntake | Cargo 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). |
Mining | Mining 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 ________/
Iis the hull’s largest principal moment of angular inertia, read off avian’sComputedAngularInertia. 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.ris the structural arm, from the hull’s centre of mass to the outer FACE of its furthest live section.structural_armmeasures it off posed colliders, so its answer is in engine world units and the caller crosses it withMeters::from_enginebeforeAttitudeEnvelopedivides by it - which is whyMETERS_PER_UNITappears in the formula above and not in the code. The Ledger corvette reads 2.76 u, i.e. 27.6 m.BodyRadiusis a scenario-obstacle radius and is not it.LOAD_LIMITiscrates/nova_events/src/scale.rsandMETERS_PER_UNITiscrates/nova_events/src/units.rs- one definition each, shared by the physics and by the player-facing formatter innova_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 / ris (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 * dtcrosses 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::ReadytoCharging. 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 readsRailgunSectionInputand 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.
WeaponsHotgates the commit and is re-read every tick: a gun safed mid-charge dumps the charge, snaps theChargecue 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 noWeaponsHot(a bare headless rig) fires freely. - The charge.
charge_secondsruns offTime, and theChargeanimation cue is SNAPPED to the gameplay fraction every tick rather than played. Both travel times on the shippedcharge_bolttrack 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_argumentsexpect. The bore is read off the RAW physics pose throughlocal_pose_in_root, notGlobalTransform, 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-Zeverywhere 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):DOCKpressed again, from EITHER hull, exactly asORBITpressed 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. TypeRenderMeshTransform.collideronBaseSectionConfig(optional) - the physics shape:Cuboid { size },Sphere { radius },Capsule { radius, length }, orCylinder { 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.Noneresolves to the unit cube (Cuboid { size: (1,1,1) }) - the shape every section had before colliders were authorable.base_sectionhands 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 aSectionFootprintof those dimensions.read_structureexpands 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
Nonewhen 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 anInlinecopy. 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 andHealthIsolated, but noSectionMarker. So it never joins the integrity graph, never counts toward the ship’s health, never wears a section’s damage effects (owning_sectionindamage_cracks.rsstops 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_fixturestakes 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 itsChildOfwhere it stands, promotes its world pose to a local one, and gives it a kick outward from the hull, a tumble and aTempEntitytimer - inheriting the ship’s motion throughinherited_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_CAPbounds it to 24 fixtures a TICK andShedBudgetbounds it to 48 a FRAME; the rest are deferred, never dropped, because shedding is what removes theChildOfa 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 smax_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, andrendergates only the meshes hung on each plate by thedress_skin_plateobserver.
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:
ShipSkinMarkerand a pose, so the shareddress_skin_plateobserver still draws it, but noSectionMarker, noColliderand no health. The placement solver never counts one as a part, the pointer never hits one, and theQpipette 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
DefaultHasheris 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
strideand a piece is yawed toPlateReading::alongor toPlateReading::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:
| subject | plates | flat | step | ridge | peak | bevel | brink | spur |
|---|---|---|---|---|---|---|---|---|
wfc_ships row | 132-162 | 6-22 | 18-22 | 0-4 | 0 | 10-14 | 48-66 | 34-42 |
| editor build | 19-27 | 0 | 2-3 | 3-8 | 1-3 | 0 | 0 | 9-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,HealthApplyDamageand theHealthZeroMarkerits 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) andcarve.rs(DamageMarksPlugin) - the two damage READINGS, below.spew.rs(CarveSpewPlugin) andchunk.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 itsStructuralCollapseThreshold. explode.rs(ExplodablePlugin) andpyre.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: firesOnNeutralizedwhen 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
ExplodableEntityplus the destroy marker, never where aMesh3dhappens to sit. A section carries its health, its collider and the marker on a gameplay root while its art hangs offSectionRenderOfdescendants under a gltfWorldAssetRoot, so anything gated onMesh3dat 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
IntegrityRootis excluded.ExplodableEntitypropagates 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 namesIntegrityRootrather thanSpaceshipRootMarkerbecause a severed wreck and an asteroid husk are roots that are not spaceships, and because this layer stays free of anythingnova_shipowns. - 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
ChunkGracea 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 fromIntegrityEnvelope, 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:
- A hit triggers
HealthApplyDamage(nova_gameplay::integrity::health); its observer subtracts the amount and addsHealthZeroMarkerat zero. The amount also bubbles upChildOf, 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_damagealso takes anat: 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. - 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. - 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.
aggregate_ship_healthkeeps the root’scurrentequal to the sum of its living sections, over amaxthat 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.- At or below its
StructuralCollapseThreshold(collapse_thresholdon the ship, default 0.05) the root getsStructuralCollapseMarkerand the ship starts TEARING ITSELF APART, rather than dying on the spot:cascade_structural_collapsedisables 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. - The ROOT dies last, and of the same rule. Each destroyed section leaves the
sum, so step 4 walks
currentdown to zero on its own; with no structure left the recompute marks the rootHealthZeroMarkerand the ordinary chain destroys it, which is what firesOnDefeated/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 threshold0.0reduces 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_RADIUS0.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_REACH4.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_MAX1.0 WORLD unit (10 m), converted into the body’s own frame byDamageMarks::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_BUDGET24. 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.
| variant | component | what it does |
|---|---|---|
Cracks | DamageCracks | Fractures 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. |
Sparks | DamageSparks | Throws sparks, faster the worse it is, past level 0.35. Removes nothing. |
Plume | DamagePlume | Guts 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_multiplierscales what a hit DEALS, clamped to[0.25, 2.0];pierce_power_multiplierscales 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.
amountdoubles 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.
amountis flat - the same bite into every layer, no speed term and no decay with depth. Crossing a layer costs that layer’sHealth.maxdivided bypierce_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’sammunition: AmmoCapacity::Limited(n)opts in (Unlimitedis the default). Unlimited fire is authored on the GUN, never granted ship-wide: a config leftUnlimited(seeSectionConfig::without_magazine) is the authoring route, and the armedammo infinitecommand /SetInfiniteAmmoaction are the runtime ones (they stripSectionAmmoandSectionReload, recording them inSuspendedSectionAmmoto restore full on the way back off).SectionReload(sections/ammo.rs): optional idle batch reload on a magazine, from the turret/torpedo/railgun config’sreload: ReloadConfig::Batch(SectionReloadConfig { delay, amount })(Disabledis the default). Seeded with the weapon’s ammunitionItemType(PdcRoundfor a turret regardless of mount damage type,RailSlugfor a railgun,Torpedofor a torpedo bay regardless of bay type). Every successful shot resets progress; every completed quiet delay movesmin(missing, amount, parent ShipInventory's count of that item)from the parent ship’sShipInventoryinto 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 aShipInventory(every ship root requires one) ortick_section_reloadpanics. Fire runs beforetick_section_reloadin FixedUpdate so a shot wins an exact completion tick. Unlimited weapons never reload. The HUD readsprogress()andincoming_rounds()to pulse only the next batch, andis_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 perDamageType, built inFromWorld. The render observer reads the round’s ownProjectileDamage.kindand hands out clones, because a turret’s authoredprojectile_render_meshis per-TURRET while the fired type comes fromLoadedBulletat runtime. Every shipped turret leaves that fieldNone, so this IS the shipped path at 100 rounds/s per muzzle: it must never allocate per shot, anddefault_projectile_render_allocates_no_assets_per_shotpins that. Its meshes come fromsections::nose_cone_mesh(a cylinder and a cone, merged), which the torpedo warhead’sDefaultTorpedoRendershares. The warhead colours ITS copy of that mesh with aStandardMaterialheld 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 anExtendedMaterialrather than aStandardMaterial, which is whySectionCracksholds a STRONG handle to itssource: once the mesh’sMeshMaterial3d<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 itsAssetIdreissued 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 indamage_cracks.rs,damage_sparks.rsanddamage_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 toDEFAULT_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.onceretires the handler the first time its filters PASS (not the first time its event fires): the loader-spawned entity is despawned, andScenarioEventConfig::build_handleris 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 bynova_assets(ready atGameAssetsStates::Loaded).CampaignConfig-id,name,scenarios(ordered member scenario ids, each a launchable chapter - naming a non-Chapterscenario 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 bynova_assetsalongsideGameScenarios.CurrentScenario(Option<ScenarioConfig>)- the loaded scenario, if any. Thescenario_is_liverun condition gates the ship input/section sets on it.
Loading / unloading (loader/)
LoadScenario(ScenarioConfig)- trigger to load: look one up inGameScenarios,commands.trigger(LoadScenario(cfg.clone()))(seeexamples/systems/system_scenario_grammar.rs). Load tears down the previous scenario, spawns the camera, input context, one handler per event, firesOnStart. 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 theLightobjects it authors, and one that authors none renders black.ScenarioLoaded- fired after a load; carriesscenario_id,handler_count,object_countfor smoke-test assertions.UnloadScenario- tears everything down and clearsCurrentScenario.ScenarioScopedMarker- any entity carrying it is despawned (recursively) on load/unload. Teardown also runsNovaEventWorld::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::resolveis idempotent, so the spawn site is unchanged - it asks theAssetServerfor the same path and gets back a handle that is already warm.ScenarioPreloadHOLDS 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_settledand 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)pausesTime<Virtual>andTime<Physics>, so the scenario clock, physics, timers and everyTime-driven system stand still; - gameplay input and both camera rigs are gated off (
scenario_play_is_freeonSpaceshipInputSystems,SpaceshipSectionSystems,NovaCameraSystemsandWASDCameraSystems::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:
| Enum | File | Trait it dispatches to | Creator reference |
|---|---|---|---|
EventConfig | events.rs | EventHandler<NovaEventWorld> (via From) | /create/events/ |
EventFilterConfig | filters.rs | EventFilter<NovaEventWorld> | /create/filters/ |
EventActionConfig | actions/mod.rs | EventAction<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:
Outcomeis not just an overlay. Setting one puts the app intoPauseStates::Pausedfor 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.SetCamerahas to WIN every frame. It dropsWASDCameraControllerand pins aScriptedCameraPosethat is re-enforced inCameraAuthoritySystems::Override, because both camera controllers keep writing the cameraTransformotherwise - the same swap the player-ship-spawn observer does.SetCameraAnchoris the same override, SOLVED every frame instead of once:ScriptedCameraAnchornames an entity, an offset in its local or the world frame, and what to look at, andtrack_scripted_camera_anchorrebuilds the pose from wherever that entity now is.ScriptedCameraTransformis the one thingOverrideenforces, 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 alook_atentity that dies falls back to the anchor. It is camera authority ONLY: it issues no helm order and does not change input authority.ReleaseCameraremoves both pose kinds. There is nothing to restore because the scenario camera IS the player’s chase camera - the chase sync keeps writing inCameraAuthoritySystems::Solvethe whole time the override is up, so dropping the override hands the view straight back.SuspendPlayerControlsets the sharedPlayerControlSuspendedgate 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.ResumePlayerControllowers it. Both are idempotent, and scenario teardown always resumes control. The flight context is lowered while suspended;Alwaysremains 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.SetSkyboxinstalls 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 withPendingSkyboxSwapandapply_pending_skybox_swapsinserts the realSkyboxConfigonce the image is in. A failed load warns and leaves the sky alone.NextScenariowithlinger: truedoes 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_sequencesruns inUpdate, chained aftersample_scenario_queriesand beforetick_scenario_timersandfire_on_update. It drainstake_ready_sequence_stepand runs the actions the step returns. Queries are sampled first so a gate filter reads this frame’s values.- A step’s
untilgate is a real handler.sequence_gate_handlerswalks a handler’s actions and spawns one extraEventHandlerper gated step, carrying a privateSequenceGateAction { 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_stepstampssince = nowon 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
NextScenariodelay ticks onTime<Virtual>, the pause-frozen scenario clock, so a player who pauses holds the cut. The menu’s one-second cut after a backdrop’sBackdropDone(nova_menu::ambience:: advance_menu_backdrop) ticks on the same clock. The scenario raises the flag in thePostUpdateevent drain, and the menu reads it in the nextUpdate. - An
Outcome’sauto_advance_secscannot, because the overlay it belongs to STOPSTime<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_scenarioreaches 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
VariableSeton 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_scenariokeeps 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. Entityis 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
EntityIdin 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_querydecides 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 toScenarioConfig::inline_querieswith 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_variableis the single write path, so every write joins a dirty set; the pulse fires when that set meets a variable anOnUpdatefilter reads. What anOnUpdatehandler 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 * 2is 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 filterlessOnUpdate, anEntityorTimerfilter, an inlineQuery(..), 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_eventsdispatcher. 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:
OnStartfires oneHudReadoutonscenario_elapsed(Timeformat) for a livemm:ss.sclock, and seeds acrashcounter that hazard-zoneOnEnterhandlers bump on each graze. Crossing FINISH bumpsgateto its terminal8.0, then TWOcrash-gatedOutcome(Victory)handlers fire in the same pulse (exactly one matches):crash == 0earns the CLEAN RUN banner,crash > 0the 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 withcargo test -p nova_assets --test scenario_gate_course.content lintchecks 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:
-
BodyRadiusis DERIVED from the mesh, not read from the config. Both ground kinds publishradius * unit_extent, and the two extents are nothing alike: an asteroid’s noise mesh puts it in[3.5, 6.0], a planet’s is1 + 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.
Lightis an ordinary spawned kind (objects/light.rs) and the engine adds none of its own, so a scenario that authors noLightrenders black. This catches every new backdrop. -
What a rock is MADE of is required, and it decides how it looks.
AsteroidConfig::kindnames a KIND -rock,metal,ice,carbon, or theplaincontrol - andobjects/asteroid_kind.rsis the one table that maps a kind to a look. It decides the LOOK and nothing else: a rock’s hit voice isImpactSurface::Rockwhatever it is made of. The look is uniform data for a triplanarExtendedMaterial(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 acontent linterror and a refusal to render, andScatterObjectswrites a kind into every copy from an authored weighted mix. -
A rock has no
healthfield, and that is not an omission. What an asteroid is made of IS its durability; the mechanism is below. It has nomassfield either: an asteroid never SOURCES a gravity well - only aPlanetor anAnchordoes - it only ever FEELS one, as a plain dynamic body. What it requires instead is an explicitinitial_velocity(MetersPerSecond3), same as aSpaceship’s: checked finite at lint and again at load, withMetersPerSecond3::ZEROan 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.massmoved toPlanet(now REQUIRED) and stayed optional onAnchor(Noneis a zero-strength well, pure geometry). Both still carry the samemu, 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 theGravitySettingscutoff it is measured against. It sets both the pulla = mu / r^2and the sphere of influence, the distance where that decays toGravitySettings::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 acontent linterror 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 lintERRORS, 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_MINis 16 andFIELD_RESOLUTION_MAXis 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^3corners is 275 KB per carved rock, paid only by rocks that are hit.FIELD_MARGINis 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_volumecompare), 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, anEventConfigvariant inevents.rs, and something that fires it (engine-driven events live inloader/-OnStartinlifecycle.rs,OnUpdateinclock.rs, the orbit/lock/docking trackers intrackers.rs; area events inobjects/area.rs;OnNeutralizedfires fromnova_gameplay’s integrity stack, and a rock’sOnDestroyedfromobjects/asteroid_carve.rswhen its field is exhausted). - Action: config struct +
EventAction<NovaEventWorld>impl in the rightactions/submodule (audio/cinematic/flow/mission/sequence/ship/spawn/timer/view), plus a row in theregistry::scenario_actions!table inactions/mod.rs. TheEventActionConfigvariant 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 inobjects/mod.rs) plus aScenarioObjectKindvariant/match inactions/spawn.rs. Its*_TYPE_NAMEconst goes innova_events, besideEntityTypeName, so readers belownova_scenariocan 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.rsandassets/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
| Driver | Does | What Nova uses it for |
|---|---|---|
AutopilotPlugin | Walks a list of named steps, each advancing when its predicate over the world holds | Headless smoke runs, driving a scenario while something else measures |
capture_window | Writes the primary window to a PNG and acks it into CaptureLog. Not a driver - the primitive a script’s shot step calls | The web figures and thumbnails, captured at 1920x1080 |
completion | The registration and exit protocol every driver reports to | Ending 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.
| Variable | Arms | Read by | Value |
|---|---|---|---|
NOVA_AUTOPILOT | the scripted state driver | AutopilotPlugin | any (presence only) |
NOVA_CAPTURE | the CAPTURE path of a script that has one: its shot steps write PNGs instead of driving straight through | capturing(), which a script reads while building its steps | any (presence only) |
NOVA_CAPTURE_DIR | nothing on its own | capture_window, and the scenario Screenshot action (nova_scenario/src/actions/view.rs) reads it independently | directory that relative capture paths resolve under; absolute paths ignore it |
NOVA_AUTOPILOT_DEADLINE | nothing on its own | the completion watcher | seconds 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:
- Register before the run starts. A collector calls
completion::registerfrom itsPlugin::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. - The app exits only when every registrant reports done. A collector calls
HarnessCompletion::doneand never writesAppExit::Successitself; 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:
| Part | Meaning |
|---|---|
name | what a log line and a stall message call this beat |
enter | the state to set on entry |
on_enter | a world action run once, on entry (a synthesized gesture, a scenario poke) |
each | a world action run every frame, with the IN-STEP elapsed seconds and the IN-STEP frame index |
until | the predicate that advances the step |
deadline | in-step seconds after which an unsatisfied until ABORTS the run, naming the step |
diagnose | a 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:
| Beat | Waits on | Why a frame count is not that |
|---|---|---|
| the widget is up | ui_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 |
| aim | pointer_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 |
| press | pointer_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 |
| release | pointer_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:
| Case | Example |
|---|---|
| render, raster or shader work coming to rest before a shot | frames(SETTLE_FRAMES) ahead of shoot |
| deterministic media duration | the 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 it | keeping 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>.webmwith VP9 video and an Opus track, rendered offline from the loaded samples at each frame’s pre-masterVoiceMix::channel_gainsand 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 withclipandloopingon a voice’s first row.gainis 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-framesstaging 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, soloop_writtennever 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()andnova_screenshot()presets,shoot, scene dressing -crates/nova_debug/src/harness.rs. - Host layer: the
probesubcommand -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: thedocking_approachexample 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.dockingcarries 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.
| Flag | Default | Meaning |
|---|---|---|
--goal | fixtures: complete objectives; New Game: create the world, fly and explore | the goal text the agent is given |
--model, --thinking | unset: pi’s own configured model and thinking level | passed straight to pi; pi agent only |
--seed | unset for fixtures; required for New Game | NOVA_SEED for the gameplay RNG; it is not the New Game world seed |
--world-seed | unset for fixtures; required for New Game | The seed the agent enters in the New Game modal |
--ticks | 18000 | the tick budget, five game minutes |
--turns | 300 | the act budget |
--deadline | 1800 | wall-clock seconds |
--out | bench-runs/<sha>/<target>/<agent>-<n>/ | the run directory (<target> is the scenario label or new-game) |
--record | unset | record each stepped tick offscreen into an empty directory, with SFX and an action-rail <dir>.mp4; see The movie |
--ui | log | log prints one line per event to stderr; quiet prints nothing |
--audit-raw | off | keep 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.
| Request | Reply | Meaning |
|---|---|---|
{"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.
| Gesture | Expands 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.targetslists visible named pointer targets, only on the main menu and while the Tab interface is open (ui.pauseisInterface).objectives,outcome(nulluntil 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_gandfree_gin grams,credits, anditemsas{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_secsofdwell_neededand thedwell_filla 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, theconnectiona clamp holds the hull to, andpair: 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 andeligible),gravity_well(the dominant well’s id),sections(the bridge, the drives, each mount with itsweapon: kind, ammo,on_target,firing),hull_platesas{total, damaged, lost}andwithheld_capabilities(what the scenario has not granted yet).contacts: every other ship withallegiance(Enemy,Player,Neutral),distance_m,bearing_deg,closing_mps,health,defeated,weapons_hot,ai_target.beaconswithdistance_mandbearing_deg.canisters: every live cargo canister, with a numeric runtimeidunique within the run (not stable across revisions),distance_mandbearing_degrelative to the player’s ship, and orderedstacksof{item, count}. Distance and bearing are absent without a player ship.bodies: the asteroids and planets, in three tiers.nearandin_the_waycarry the full record -kind,radius_m,distance_mto the centre,surface_mto the surface,bearing_deg,closing_mps- andin_the_wayaddswhy. Thekindsays whether a body can be destroyed: everyAsteroidcan, noPlanetcan. Everything else is one summary per{distance band}.{bearing sector}group with akey, acountand the nearest surface, farthest surface and largest radius in it;observe {"expand": ["<key>"]}opens one back up andexpandedlists the keys that actually opened, because a key can go stale as the ship moves. See Why the bodies are tiered.ordnanceas{inbound, outbound}counts.objective_log: every objective card posted and completed, in order.cinematic(only while a scene plays) as{playing, skippable}, andcheats_marked(only when the run armed cheats).inputs.live(registry wire names that are not locked out, never dynamicsection.<id>wires),inputs.held, andinputs.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 tocommandgestures).
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: spawnspi --mode rpcwith the built-in tools off and the relay extension on, sends one prompt (the pilot manual incrates/nova_bench/src/manual.md, the scenario, the goal, the first view; the game knowledge behind it sits incrates/nova_bench/src/pages/, read on demand through thepagetool) and consumes the RPC event stream: assistant text, thinking, tool calls and usage land in the audit.NOVA_BENCH_PInames the binary whenpiis not on the path. If pi settles with the run still open it is nudged once; a second settle ends the run asagent_exit.cmd:<argv>: any process that speaks the referee protocol. It getsNOVA_BENCH_SOCKETin its environment; its stdout and stderr go toagent.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:
| Metric | Source |
|---|---|
outcome | the mission outcome at the end: victory, defeat, none |
objectives_seen, objectives_completed | ids 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_seconds | the referee’s clock |
turns, gestures | act calls, and the wire lines they expanded to |
damage_taken | the sum of health decreases on the player ship |
sections_lost | player sections that stopped being alive |
ammo_spent | the sum of rounds decreases across the player’s mounts; a reload is an increase and does not count |
kills | Enemy ships that turned defeated |
bad_lines | wire 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 |
cheated | whether the run armed the command shell’s cheats, copied from the snapshot’s mark |
world_seed | the authoritative New Game session seed after setup; null for scenario plays |
llm | pi only: messages, input, output, cached tokens and cost from the usage events |
ended_by, agent_status, agent_report | why it ended, and what the agent said |
end | where 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-rawkeeps 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:
| Verdict | Meaning | Exit |
|---|---|---|
replay_match | byte for byte | 0 |
replay_close | same tick, outcome and ships down; positions and the health of the ships still standing within 5 % or 10 m | 0 |
replay_mismatch | anything else | 1 |
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
| Agent | Outcome | Ticks | Turns | Ammo | Damage | Wall | Cost |
|---|---|---|---|---|---|---|---|
baseline | victory | 661 | 22 | 3630 | 0 | 5.4 s | 0 |
pi gpt-5.6-luna, low thinking | victory | 481 | 5 | 2940 | 0 | 33 s | $0.005 |
pi gpt-5.6-luna, low thinking | victory | 971 | 12 | 4128 | 66 | 66 s | $0.012 |
pi gpt-5.6-luna, low thinking, --record | victory | 1241 | 11 | 3416 | 413 | 114 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.
| Goal | Agent | Ended by | Ticks | Turns | Ammo | Damage | Wall | Cost | End row |
|---|---|---|---|---|---|---|---|---|---|
| orbit the planetoid | pi, first manual | agent exit | 2041 | 10 | 0 | 7508 | 70 s | $0.012 | helm manual, well planetoid, body planetoid 7 m |
| orbit the planetoid | pi, manual with the speed band | finish, done | 1646 | 7 | 0 | 0 | 41 s | $0.007 | helm Orbit planetoid (Hold), body planetoid 2123 m |
| park on the derelict | pi | finish, done | 4581 | 21 | 0 | 0 | 115 s | $0.024 | helm manual, contact derelict 172 m |
| destroy the derelict | cmd: probe | finish, 1 kill | 3371 | 34 | 3000 | 0 | 25 s | 0 | no contact left |
| destroy the derelict | pi, first manual | finish, gave up | 10903 | 21 | 5136 | 2748 | 143 s | $0.029 | helm manual, contact derelict 5787 m |
| destroy the derelict | pi, manual with range control | finish, done, 1 kill | 2626 | 11 | 1096 | 0 | 65 s | $0.011 | no 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/orsrc/. The measurement knobs are computed from one prefix bynova_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/andtests/forNOVA_*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.mdNova 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 runor 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.
| Variable | Gates | For |
|---|---|---|
NOVA_AUTOPILOT | arms the scripted state driver; unset, the plugin adds nothing. Also makes the settings store INERT | harness |
NOVA_AUTOPILOT_DEADLINE | seconds before the completion watcher error-exits naming the laggards | harness |
NOVA_CAPTURE | puts a script on its CAPTURE path - its shot beats write PNGs, its loops record. Also makes the settings store INERT | harness |
NOVA_CAPTURE_DIR | directory relative capture paths stage under; absolute paths ignore it | harness |
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.
| Variable | Gates | For |
|---|---|---|
NOVA_PROBE | arms frame-time capture, the scene census and the frame-cost breakdown | harness |
NOVA_PROBE_MODE | correctness drops the measuring passes from a child run | harness |
NOVA_PROBE_WARMUP / _FRAMES | the capture window, in frames; wins over an example’s declared one | harness |
NOVA_PROBE_OUT | directory the run writes frametime.csv, <label>.json and census.json into | harness |
NOVA_PROBE_LABEL | the row label a capture records itself under | harness |
NOVA_PROBE_RES | forced primary-window resolution for the measured run | harness |
NOVA_PROBE_RENDER_SCALE | forces the render-scale lever, holding the rest of the quality preset fixed | harness |
NOVA_PROBE_MAX_DELTA | ceiling on how many fixed steps one frame may run | harness |
NOVA_PROBE_PRESENT | presentation mode forced on the primary window | harness |
NOVA_PROBE_QUALITY | graphics preset for the run, recorded in the metadata | harness |
NOVA_PROBE_SCENARIO | the scenario a sweep cell measures | harness |
NOVA_PROBE_SHA / _HOST | override the recorded git SHA and host tag | harness |
NOVA_PROBE_CENSUS_FRAME | frames after Playing at which the scene census is taken | harness |
NOVA_PROBE_FRAMECOST_FRAMES | frames averaged into one frame-cost report | harness |
NOVA_PROBE_RENDER_DIAG | asks the renderer for GPU timestamp queries, so passes can be named | harness |
NOVA_PROBE_TIMELINE | JSONL path for the run timeline: states, events, variables, markers | harness |
NOVA_PROBE_INVARIANTS | arms the continuous engine-bound invariant checks | harness |
NOVA_PROBE_CONTRACT | JSON path the run declares its wired capabilities to | harness |
NOVA_PROBE_SNAPSHOT / _SNAPSHOT_FRAMES | JSONL path for world-state snapshots, and the frames to take them at | harness |
NOVA_PROBE_STEPDIAG | CSV path for the per-fixed-step physics diagnostics | harness |
NOVA_PROBE_STEPDIAG_BODIES | the body-count REGIME floor its end-of-run summary is taken over | harness |
NOVA_PROBE_SANDBOX_RESOLVER_CHILD | marks the re-executed child in the probe host’s own sandbox test | harness |
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.
| Variable | Flag | Gates | Owner | For |
|---|---|---|---|---|
NOVA_NORENDER | --norender | every AppBuilder::new() in the process assembles a headless app: no device, no window, no winit | nova_core | harness |
NOVA_MUTE | --mute | zeroes the audio OUTPUT; the volume setting is untouched | nova_gameplay | player |
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.
| Variable | Gates | For |
|---|---|---|
NOVA_BENCH_SOCKET | the unix socket the referee answers on; bench play sets it for the agent process, and a cmd: agent reads it | harness |
NOVA_BENCH_PI | the pi binary bench play --agent pi spawns; unset, pi on the path | harness |
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.
| Variable | Gates | For |
|---|---|---|
NOVA_SEED | seeds the gameplay RNG with one u64, so a driven run replays byte for byte; unset, the OS seeds it and no two runs agree | harness |
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.
| Variable | Gates | For |
|---|---|---|
NOVA_MODDING_CACHE_ROOT | moves the local mod cache off the platform data dir | tooling |
NOVA_MODDING_PORTAL_URL | points a native build at another portal tree | tooling |
NOVA_CONFIG_ROOT | moves the settings store off the platform config dir | tooling |
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
| Variable | Gates | Owner | For |
|---|---|---|---|
NOVA_MENU_BACKDROP | pins 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 draw | nova_menu | harness |
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_LOOPandNOVA_STANDOFF_TRACEbelong to one example each and stay literals beside the range that reads them. The two railgun names pick which of the shipped loopsscreenshot_railgunrecords - the aftermath cut, and the real-speed pass against the default slowed clock - andscripts/capture-web-media.shsets them. Neither goes wrong quietly.NOVA_RAILGUN_AFTERMATHis 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 forgetsNOVA_RAILGUN_LIVE=1records the SLOWED loop name, so the live row’s file never appears - and the scriptrm -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
*_LOOPnames put one range each on its RECORDING path instead of its reading one, andscripts/capture-web-media.shsets them.NOVA_WAKE_LOOP=1recordsrailgun_wake_bench’s close pass asnews-0130-railgun-wakeinstead of walking the stills.NOVA_COLLAPSE_LOOP=1recordsstress_hull_collapse’s collapse asnews-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=1recordssystem_lock_line_of_sight’s drifting cover asnews-0130-lock-occlusionand runs no assertions at all. Each takes0or1, and any other value is an authoring error that panics rather than picking a path.NOVA_STANDOFF_TRACEis a diagnostic and not a mode: set to anything, it addsloop_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 areconst Color, layout and volume values innova_interface,nova_commandandnova_gameplay::audio. A grep forNOVA_[A-Z_]*is dominated by them, so countenv::varcall sites instead of identifiers. -
Shell-only.
NOVA_UI_PORT,NOVA_GAME_PORT,NOVA_MODS_PORT,NOVA_MODS_DIR,NOVA_PORT_LO/_HIare read byscripts/andweb/webpack.config.js. No Rust reads any of them.The media packagers own two more.
NOVA_UNFREEZEtakes a comma-separated prefix list and re-opens the frozennews-names of the post being authored (NOVA_UNFREEZE=news-0130), inscripts/gen-web-screenshots.pyfor stills andscripts/capture-web-media.shfor loops; without it a name a post already ships is never overwritten.NOVA_REUSE_STAGE=1tellsscripts/capture-web-media.shto package a completedtarget/loop-shotsset rather than re-run the producers, which is the repackage path after a capture that finished. -
Retired.
NOVA_MENU_PATHis read by nothing.examples/ui/editor.rsowns the create-a-ship-and-Play sequence end to end, sosystem_menu_bootdropped 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
- 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 inNovaGameplayPlugin, one of them able topanic!on a malformed value, are the worked example of getting this wrong. - Export it through that module’s prelude.
- Add it to the roster in
tests/env_contract.rs. The test fails until you do, which is the point. - 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 / timestepthe 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 fullNOVA_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 onnova_core::RENDER_DIAG_ENV. - What an armed run looks like from outside:
nova_core::PROBE_ENVandnova_core::MEASURE_WINDOW_CLASS-crates/nova_core/src/lib.rs.nova_probere-exportsPROBE_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_repeatsandRefreshCapincrates/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).
-
New config module. Create
crates/nova_ship/src/sections/<kind>_section.rs, modelled onhull_section.rs(simplest) orturret_section/(a multi-file module: behavior + FixedUpdate systems). It defines: a<Kind>SectionConfigstruct, a<kind>_sectionbundle fn, a<Kind>SectionMarkercomponent, a<Kind>SectionPlugin, and apreludere-exporting them. The bundle MUST insert the marker and theSectionClassfor 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>SectionConfigis typedMeters,MetersPerSecondorMetersPerSecondSquared(nova_events::units) and never a baref32- 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’sm/m/s/m/s2from, andto_engine()where the value reaches Bevy or avian is the one crossing. What stays a baref32is 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: addpub mod <kind>_section;and re-export<kind>_section::prelude::*in the moduleprelude. -
Add the enum variant. In
crates/nova_ship/src/sections/base_section.rs, add the variant toSectionKind(grep forenum SectionKind):#![allow(unused)] fn main() { pub enum SectionKind { Hull(HullSectionConfig), Thruster(ThrusterSectionConfig), Controller(ControllerSectionConfig), Turret(TurretSectionConfig), Torpedo(TorpedoSectionConfig), Shield(ShieldSectionConfig), } } -
Section class. Add the variant to
SectionClassincrates/nova_gameplay/src/damage.rs(grep forenum 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 itshealth; how far a round gets through it is the travel rule, which readsHealth.maxand 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_descriptionandkind_indexincrates/nova_interface/src/ship/sections.rs, and toSectionIconType::ofincrates/nova_interface/src/icons.rs. -
Wire the section plugin. In
crates/nova_ship/src/sections/mod.rs, add your plugin to theadd_plugins((...))tuple inSpaceshipSectionPlugin::build(grep forimpl Plugin for SpaceshipSectionPlugin), passing therenderflag like the others:#![allow(unused)] fn main() { <kind>_section::ShieldSectionPlugin { render: self.render, }, } -
Spawn arm. In
crates/nova_scenario/src/objects/spaceship.rs, add a match arm toinsert_spaceship_sections(grep for it, then itsmatch &config.kind). At minimum insert the kind bundle; add input-binding handling only if your kind needs it (see theTurret/Thrusterarms 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.
-
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, orvec![]if it takes none - modelHullfor unbindable,ThrusterorTurretfor bindable.insert_preview_section(crates/nova_editor/src/preview.rs) inserts the<kind>_section(...)bundle beside the sharedpreview_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 intoplayer_config.sectionsis likewise generic (register_preview_section), so the arms only insert. -
Parts gallery category + readouts. In
crates/nova_editor/src/gallery/catalog.rs, add aGalleryCategoryvariant (with itsROWentry,label()andaccepts()arms), then arms tokind_label()andbehaviour(). All of them matchSectionKindexhaustively, 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))] } } -
Asset prototype. Add a family module under
crates/nova_authoring/src/base_content/sections/(or extend the one this kind belongs to) and return yourSectionConfigfrom itsprototypes(), so the catalog ships a ready-to-place instance. Give it a stable snake_caseid(this is whatsections.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 aconstincrates/nova_ship/src/sections/catalog_ids.rsonly when a crate that cannot reachnova_authoringhas 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_effectsis a real design decision per kind, not boilerplate. The shipped rule: a hull authors nothing (defaulting to[Cracks]), anything carrying machinery addsSparks, and a thruster addsPlumeon 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 toBaseContentAssetsand itsfrom_paths()incrates/nova_authoring/src/base_content/assets.rs.The builders do not feed the game directly: regenerate the committed RON with
cargo run content genand commitassets/base/sections/base.content.ronwith the code change - thecontent_ron_paritytest fails on drift. -
Example. Add
examples/systems/<what it proves>.rs, modelled on the existing per-section ranges (system_attitude_hold.rsandsystem_thrust_and_plume.rsare 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_diskincrates/nova_probe_cli/tests/catalog_drift.rsfails until disk and catalog agree, andsystems_ranges_assert_their_invariant_rosterbeside it fails until the new range has a named invariant roster. The example builds a minimalScenarioConfig(a controller + your section), triggersLoadScenario(...), and under--features debugdrives 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 moduleshull_section.rs(minimal) andturret_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 oneprototypes()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 intoGameObjectives).world.next_scenario = Some(NextScenarioActionConfig { .. })- queue a scenario switch.world.push_command(|commands| ...)- defer anything that needs real world access (spawning, querying entities, resource mutation). The closure gets a&mut Commands; for a full&mut World(id -> Entity lookups) queue acommands.queue(move |world: &mut World| ...)inside it, the shapeDespawnScenarioObjectActionConfiguses.
Nothing runs against the world synchronously. Each frame
NovaEventWorld::state_to_world_system (in world.rs) syncs objectives into
GameObjectives, runs a queued non-lingering NextScenario switch, then drains
the command queue - so every push_command closure lands at frame end, in
order. The unit tests in the actions/ submodules exercise exactly this:
mutate a NovaEventWorld, call
NovaEventWorld::state_to_world_system(&mut world) to
drain, then assert on the world (see
despawn_action_removes_the_scoped_object_by_id in actions/spawn.rs).
Recipe 1: add an event kind
An event is fired somewhere in the engine, and scenarios react to it through a
handler. Adding one is two files: the event type (nova_events) and its config
variant (nova_scenario), plus the firing site.
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.
-
In
crates/nova_events/src/lib.rsdefine the marker event and its info struct with theEventKindderive. The info is what a handler’s filters read; give it the pair shape (id,other_id,other_type_name) if it targets an object so theEntityfilter composes like the others.#![allow(unused)] fn main() { #[derive(Debug, Clone, EventKind, Reflect)] #[event_name("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_eventsprelude (thepub use super::{...}block at the top oflib.rs). -
In
crates/nova_scenario/src/events.rsadd a ROW to thescenario_events!table near the bottom of the file. The enum, theFrom<EventConfig> for EventHandler<NovaEventWorld>arm,ALL,COUNT,nameandlabelare 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
labelis the row the editor’s trigger menu lists it under. -
Fire it. Engine-driven events fire from
crates/nova_scenario/src/loader/withcommands.fire::<OnScanCompleteEvent>(OnScanCompleteEventInfo { .. })(see theOnStartsite inloader/lifecycle.rs,OnUpdateinloader/clock.rs, and orbit-lifecycle/lock/docking events inloader/trackers.rs); object-local events (an area entering/leaving) fire from the object’s own observer, the wayobjects/area.rsfiresOnEnterEventfrom its own trigger. A kind may also fire from a system it owns:objects/asteroid_carve.rsfiresOnDestroyedEventout ofcarve_asteroid_fieldswhen a rock’s field is exhausted, because “destroyed” there is a geometry test rather than a health-zero marker.
EventConfig is Copy and derives serde, so the new variant is authorable from
RON with no extra work.
Recipe 2: add an event filter
A filter gates whether a handler’s actions run; all filters on a handler must
pass. Everything lives in crates/nova_scenario/src/filters.rs.
-
Define the config struct and its
EventFilter<NovaEventWorld>impl.filterreturns a bool and may readworld(variables) andinfo(the fired event data); it must not mutate.#![allow(unused)] fn main() { #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct VariablePresentFilterConfig { pub key: String, } impl EventFilter<NovaEventWorld> for VariablePresentFilterConfig { fn filter(&self, world: &NovaEventWorld, _: &GameEventInfo) -> bool { world.get_variable(&self.key).is_some() } } } -
Add the variant to
EventFilterConfig(grep forenum EventFilterConfig) and the arm toimpl EventFilter<NovaEventWorld> for EventFilterConfig(grep forimpl EventFilter<NovaEventWorld> for EventFilterConfig):#![allow(unused)] fn main() { pub enum EventFilterConfig { Entity(EntityFilterConfig), Conditional(ConditionalFilterConfig), Expression(ExpressionFilterConfig), VariablePresent(VariablePresentFilterConfig), } // in the filter match: EventFilterConfig::VariablePresent(config) => config.filter(world, info), } -
Export the config struct from the module
prelude(thepub use super::{...}block at the top offilters.rs). -
Make it authorable in the editor (see Two surfaces author the vocabulary): derive
Reflecton the config, tag any string that names something with theNamesattribute, and add theFilterChoicevariant incrates/nova_editor/src/event.rs-ALL,label,stem,operands,stock, plus thefilter_choice,filter_configandfilter_config_mutarms. The matches are exhaustive, so the compiler names every one of them.
Recipe 3: add an event action
An action runs when a handler passes, in order. Everything lives in
crates/nova_scenario/src/actions/: the 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.
-
Define the config struct and its
EventAction<NovaEventWorld>impl.fn action(&self, world: &mut NovaEventWorld, info: &GameEventInfo)mutates the seam and nothing else - useworld.insert_variable,world.push_objective,world.next_scenario, orworld.push_command(...)for world access. Anything needing an id -> Entity lookup queues acommands.queue(move |world: &mut World| ...)inside the pushed command, scoped withWith<ScenarioScopedMarker>(a raw id match would also hit ship sections that carryEntityId).#![allow(unused)] fn main() { #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct VariableClearActionConfig { pub key: String, } impl EventAction<NovaEventWorld> for VariableClearActionConfig { fn action(&self, world: &mut NovaEventWorld, _: &GameEventInfo) { world.insert_variable(self.key.clone(), VariableLiteral::Boolean(false)); } } } -
Add a ROW to the
scenario_actions!table inactions/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 ISActionTag::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:
effectis the creative-map class.Bookkeepingtouches only scenario state and presentation;Injectionreaches into the simulation in a way playing could not have produced, and the badge reports it by name;Nestedmeans the class is whatever the actions it schedules are worth.inspectisReflectfor every action but one: the editor draws the payload’s fields from the reflection.Opaqueis 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_chainandstep_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. -
Export the config struct from the
actions/mod.rspreludeblock. -
Make it authorable in the editor: derive
Reflectand tag the naming strings, as in recipe 2. The editor’s list is not its own -ActionChoiceis a type alias for the scenario crate’sActionTag(crates/nova_editor/src/event.rs), soALL,labelandstemcome 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 inActionChoiceExt::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
ActionKindvariant of its own, and owes arms inaction_config,action_config_mutandaction_choicebeside it.SequenceandCinematicare there because their steps are children;VariableSetbecause its panel is a head. All three matches are exhaustive. -
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.rsdeclaresRigidBody::Static(the base bundle supplies no body), andlight.rsis a pure-render kind: it splits config from component with anAddobserver so therenderflag can skip the Bevy light entirely for headless tools. Note that scene lighting itself is authored content - a scenario with noLightobject renders black, so any new example or fixture that renders needs one.
-
Add the type-name const to
crates/nova_events/src/lib.rs, besideEntityTypeNameand the other*_TYPE_NAMEvalues, and export it from that crate’s prelude. It goes there, not beside the object, because a reader that matches on it -nova_interface’s map,nova_gameplay- must not depend onnova_scenarioto name a kind (CONVENTIONS, Nova 5).#![allow(unused)] fn main() { /// [`EntityTypeName`] value for an authored mine. pub const MINE_TYPE_NAME: &str = "mine"; } -
Create
crates/nova_scenario/src/objects/<kind>.rs. It holds a config struct, a marker component, a<kind>_scenario_object(config) -> impl Bundlebuilder, and (optionally) aPluginfor any observers/systems the kind needs. The bundle carries the marker plus anEntityTypeName; the sharedbase_scenario_object(id, name, transform, visibility,ScenarioScopedMarker) is added by the spawn path, not here. It deliberately carries NO body - each kind declares its ownRigidBody(the asteroid addsDynamic+TransformInterpolation; three of the six kinds are static).Every distance, speed or acceleration the config carries is
Meters,MetersPerSecond,MetersPerSecondSquared, orMetersPerSecond3(nova_events::units), never a baref32: the TYPE is what says what the number means, it is what the editor stamps a row’s unit from, andto_engine()at the spawn site is the one place it becomes a Bevy or avian number. Vector positions areMeters3; vector velocities areMetersPerSecond3. 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
Stringid 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 - acontent linterror first, a loud refusal on the load path second. It is never resolved to a house answer. AnOptionis 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::massis required because every planet is a static gravity well. There is noAsteroidConfig::massfield: mobile asteroids and ship roots respond to gravity rather than authoring wells.AsteroidConfig::kindis a plain requiredAsteroidKindId, 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}; } } -
Register the module in
crates/nova_scenario/src/objects/mod.rs: addpub mod <kind>;, re-export<kind>::prelude::*from themod.rsprelude, and if the kind has a plugin add it inScenarioObjectsPlugin::build(likeAsteroidPlugin, which takesrender). -
In
crates/nova_scenario/src/actions/spawn.rsadd the variant toScenarioObjectKind(grep forenum ScenarioObjectKind) and the spawn arm inimpl EventAction<NovaEventWorld> for ScenarioObjectConfig:#![allow(unused)] fn main() { pub enum ScenarioObjectKind { Asteroid(AsteroidConfig), Spaceship(SpaceshipConfig), Beacon(BeaconConfig), 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:
| Recipe | What the editor needs |
|---|---|
| Event kind | Nothing. 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. |
| Filter | Reflect + Names on the config, and a FilterChoice variant with its stock value. |
| Action | Reflect + 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 kind | Reflect + Names on the config, an ObjectChoice variant in crates/nova_editor/src/node.rs to place it with, and the arms the compiler then asks for (glyph, preview, stage, inspect). |
stock is what the kind switch puts on a node the moment it is switched TO -
a valid config with empty ids, never a Default that lowers into something the
lint refuses.
Checklist
Whichever recipe you follow, the change is done when: the config struct derives
Clone, Debug, Reflect and the serde pair; every field says what it is for
in a doc comment; every string that names something carries its Names
attribute; every file a builder may change is an AssetRef<A> the reflection
can see; the 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 theEventKindderive -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 undercrates/nova_scenario/src/objects/. - The seam:
NovaEventWorld-crates/nova_scenario/src/world.rs. - What a string names:
Names-crates/nova_scenario/src/names.rs; the editor’s choices:FilterChoice, andActionChoice(an alias fornova_scenario’sActionTag) withActionChoiceExt::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.
| Fact | Where | What it already means |
|---|---|---|
grid.length = 11 on z | WfcGrid, authored | the ship’s long axis |
grid.height = 5 on y; the keel row is height / 2 | WfcGrid; TileSet::hull | up, and a middle |
x = 0 is the mirror plane | Grid::starboard_half starts at x = 0.5 | port/starboard |
vacuum.bow_taper = 24.0 | WfcVacuum, read by Plan::vacuum_weight | z = 0 is the BOW |
vacuum.stern = 9.0 | WfcVacuum, same reader | the last row is sparse |
seed_keel | collapses the spine by hand, from keel.hull | a connected structure to grow on |
keel.bridge at length / 3 | the bridge, forward of centre | a 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:
| # | Then | Now |
|---|---|---|
| 1. Per-part aim | done | shipped, as WfcPart::aim |
| 2. Seed the drive deck | done | shipped, and it seeds a multi-cell drive whole |
| 3. Deck and belly | measured, not landed | unchanged: measured, not landed, still a taste call |
| 4. Seed a superstructure | proposal | not 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 weights | last | not 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, becauseVACUUMis 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 aty = HEIGHT - 1aroundz = 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::Uprules somewhere meaningful to fire. - Risk, and it was real: a seeded tower is a stud, and
erode_studsexists to take those off.runnow marks every cell the seed passes write, widens the mark over a part’s joints, anderode_studsskips it - so a tower seeded the way the stern is inherits THAT exemption rather than needing its own. It inherits nothing else:keel_componentanderode_blocked_exitsdrop parts through the samedrop_partand 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 aty = HEIGHT - 1reaches 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 12prints 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 tunesstandard_hullrather 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:
- Boris the Brave, Driven WaveFunctionCollapse - https://www.boristhebrave.com/2021/06/06/driven-wavefunctioncollapse/
- Boris the Brave, WFC Tips and Tricks (fixed tiles, biome filtering) - https://www.boristhebrave.com/2020/02/08/wave-function-collapse-tips-and-tricks/
- Boris the Brave, Arc Consistency Explained (unary constraints are domain filters) - https://www.boristhebrave.com/2021/08/30/arc-consistency-explained/
- Boris the Brave, Constraint-Based Tile Generators (hard rules versus the soft heuristic) - https://www.boristhebrave.com/2021/10/31/constraint-based-tile-generators/
- Boris the Brave, Model Synthesis and Modifying in Blocks - https://www.boristhebrave.com/2021/10/26/model-synthesis-and-modifying-in-blocks/
- Paul Merrell, model synthesis and graph grammars - https://paulmerrell.org/model-synthesis/, https://paulmerrell.org/grammar/
- gridbugs, Wave Function Collapse (the weighted-entropy formula) - https://www.gridbugs.org/wave-function-collapse/
- mxgmn/WaveFunctionCollapse (README: “the algorithm doesn’t know about global structure”) - https://github.com/mxgmn/WaveFunctionCollapse
- a1studmuffin/SpaceshipGenerator, MIT - https://github.com/a1studmuffin/SpaceshipGenerator
- Oskar Stalberg, Wave Function Collapse in Bad North, EPC2018 - https://www.youtube.com/watch?v=0bcZb-SsnrA