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. |
| 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 unit); 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; the hull may survive. |
| 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); 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). |
| 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 | FlightVerb, Autopilot | Controller sections grant verbs; the flight layer flies them (NovaFlightPlugin); PDControllerPlugin is the attitude loop. |
| 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; anchors and asteroids publish one. |
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, 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 |
|---|---|---|---|---|
| NOVA OS (terminal, shell, ship computer apps) | nova_os, nova_os_ui | crates/nova_os/src/command.rs, crates/nova_os/src/app.rs, crates/nova_os_ui/src/lib.rs | NovaOsUiPlugin | Pure model in nova_os (NovaOsCommandRegistry, NovaOsAppRuntime); the Tab CRT monitor and the map/ship apps in nova_os_ui. |
| Keybinds: the action table, rebinding, the 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 random menu_backdrop-flagged scenario drawn live (load_menu_ambience); NOVA_MENU_BACKDROP pins one id for capture. |
| Shared UI theme, widgets, skins | nova_ui | crates/nova_ui/src/lib.rs, crates/nova_ui/src/widget/mod.rs | NovaUiPlugin | Theme tokens, the UiSkin switch, 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, generated hulls | examples | examples/playable/wfc_arena.rs, examples/playable/shared/wfc.rs | wfc_hull | Wave-function collapse over real section prototypes into flyable hulls; the arena’s lobby/pause/result match flow sits in examples/playable/wfc_arena/. |
| Debug tooling (inspector, overlays, F12 screenshots) | nova_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 broadside # the game, straight into one scenario
cargo run --features dev # + debug tooling (inspector, wireframe)
cargo run --example system_scenario_grammar # run an example
cargo build --release # release profile: opt=s, lto, stripped
cargo check && cargo fmt # before committing
cargo test --workspace # full suite (CI runs this; skip locally unless asked)
cargo run content lint # validate content: refs + balance + input overlaps (also: gen)
cargo run --features debug probe run system_player_path # run-harness check (correctness + perf)
Notes that keep the suite honest and fast:
- Use
cargo test --workspace, never 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),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) andcompare_asteroids/compare_planets(the number keys re-dress the focus subject). 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),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). 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_outcomes(die -> the Defeat overlay -> Retry -> a clean reload -> kill -> the objective and the CHECKPOINT -> Continue -> the chained scenario),bug_neutralized_quiet(a wreck’s point defence stands down) 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) andbug_menu_picker(the Scenarios picker, whose pane split must not depend on the selection - real fonts, real taffy, which a headless unit rig cannot measure at all),bug_sandbox_soak(the editor sandbox entered and then left alone, holding one physics step to its own timestep),bug_carve_apply(one cut that severs a rock, and what the main thread pays to SWAP the result in - the range counts grids rather than milliseconds, so it reads the same on every box) andsystem_nova_os(the Tab ship computer, opened with a keystroke and clicked THROUGH the CRT glass, so the whole forwarded-pointer chain - window rect, screen-to-image mapping, offscreen UI stack,Activate- is asserted live rather than one link at a time). Finally the STRESS ranges, one file each:stress_bullets,stress_torpedoes,stress_point_defense,stress_one_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. 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 -bug_menu_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 shots (screenshot_gravity,screenshot_hero_ship), the menu and editor walks (screenshot_menu,screenshot_scenario_picker,screenshot_editor), the section closeups (screenshot_section_frame,screenshot_section_weapons), the Tab ship computer (screenshot_nova_os_terminal,screenshot_nova_os_apps), the Rock hollow combat beats (screenshot_radar_lock,screenshot_contextual_hud,screenshot_combat_lock,screenshot_combat_hud,screenshot_combat_wide,screenshot_hull_juice,screenshot_torpedo_run) and the flight computer around a real well (screenshot_orbit,screenshot_goto_burn,screenshot_flip_burn). The two webm producers areloop_torpedo_blastandloop_spine_cut. The posed LINEUPS live here too -screenshot_thruster_gallery(the shipped drive, the proposed shell family and the CC0 candidates in one named row) 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.
When adding a substantial feature, add or extend the range that drives it. When
fixing a bug, WRITE the range that reproduces it first: that is the doctrine in
AGENTS.md, and the invariant roster is what keeps it honest.
Every example is HARNESSED: it drives itself under
NOVA_AUTOPILOT=1, and probe is the regression suite over all of them -
cargo run --features debug probe run systems (or screenshots, or
playable) runs one category alone, and --all is the whole catalog, which is
what CI runs. Each example must reach Playing and exit without panic; every
systems/ range additionally carries panic-on-failure behavior assertions with
completion backstops (a stalled script fails instead of passing vacuously). The
rosters are pinned by the display-free
systems_ranges_assert_their_invariant_roster, so an invariant cannot be
deleted into a still-green run. The screenshots/ and playable/ examples
carry no behavior assertions of their own - they drive the shipped
scenes to capture frames - but every one walks an AutopilotPlugin step
timeline, so a beat that never resolves is an error exit naming that step, and
every one adds nova_probe::NovaProbePlugin, so a probe run grades the walk on
the engine invariants. Disk and catalog
cannot drift: the display-free catalog_matches_disk test
(crates/nova_probe_cli/tests/catalog_drift.rs) fails cargo test --workspace
when a new example misses its [[example]] block. That is the case nothing else
catches - with auto-discovery off, an uncataloged example file does not build at
all and no other tool says so.
The drivers themselves - AutopilotPlugin, the screenshot capture,
the completion protocol, and the full NOVA_* environment contract - live in
the nova_autopilot crate and are documented on
The automation harness. This page only shows the run
recipes; that page is the contract.
Harness runs are SILENT: any harness env (NOVA_AUTOPILOT,
NOVA_CAPTURE) zeroes the audio output via HarnessMute - Xvfb hides the
window but not the speakers, and nobody listens to a scripted run. The
volume SETTING is untouched (persistence and the settings menu never see
the mute). NOVA_MUTE=0 forces sound through a harness run;
NOVA_MUTE=1 mutes a normal one, and the game binary’s --mute flag does the
same - see the outputs-off pair above.
Examples as bug pins
A bug becomes a RANGE (AGENTS.md): reproduce it in examples/systems/
before the fix, and the fix is what turns the range green. A unit/App test
still pins a system-level mechanism, but anything that only manifests in a
composed scene belongs in a range (for example, system_menu_boot runs the
shipped boot flow with the ECS fallback error handler swapped to panic, so
unhandled command errors on those transitions fail CI). A range’s pin is an
autopilot-script assertion (a named step whose on_enter asserts, reached only
once the steps before it have waited on the world - see
system_hull_damage/system_hud_indicators for the style) carrying an
outcome: <slug>
marker on the roster; CI’s probe sweep runs it on every push. Caveat: the handler swap
does NOT catch remove/despawn command warns (they bake in the WARN handler
at queue time).
Launching a scenario from the command line
--scenario <id> on the game binary boots straight into that scenario, past
the main menu - for anyone who would rather not click through the picker, and
for a mod author testing one scenario id in one command:
cargo run -- --scenario broadside # a base scenario
cargo run -- --scenario my_mod_intro # anything an ENABLED mod registers
cargo run -- --scenario nope # refuses, and lists every id
- The id is matched against the MERGED registry (base plus every enabled mod),
so a mod’s scenarios are launchable by id and
hiddenchapters and menu backdrops are too - a superset of the rows the Scenarios picker shows. - An unknown id prints the full id list to stderr and exits non-zero. The check happens once the content merge has run, which is after the window opens: the merged registry does not exist before that.
- The launch is the picker’s own door (
NewGameScenario+GameMode::NewGameGameStates::Playing), so the scenario comes up through the same loader and the same non-blocking load screen as a click on Play.
- Native only. The wasm bundle has no command line.
Content CLI
content (nova_authoring::cli::main, crates/nova_authoring/src/cli.rs; a
subcommand of the game binary, not a separate bin) authors and validates the
game’s content. Two subcommands, run from the repo root:
cargo run content gen # regenerate the base *.content.ron
cargo run content lint # lint the whole content tree
cargo run content lint --target <mod> # lint one mod (dir, id, or `base`)
cargo run content lint --target <mod> --report r.md # + write a per-mod report (md|html)
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 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 (needs a display + GPU; headless CI-style is Xvfb + lavapipe) into a
staging dir, then package into web/src/assets/:
export NOVA_CAPTURE_DIR=target/shots
for shot in $(python3 scripts/gen-web-screenshots.py --producers); do
NOVA_AUTOPILOT=1 NOVA_CAPTURE=1 cargo run --example "$shot" --features debug
done
python3 scripts/gen-web-screenshots.py # validate + copy; build composites; write the 44x44 icons
The capture examples run headless under NOVA_AUTOPILOT: each is one autopilot
script whose steps pose the camera and shoot, and NOVA_CAPTURE is what makes
those shot steps write 1920x1080 PNGs rather than drive straight through. The Python step validates
each shot is 16:9, copies it in, builds the composite shots a single capture
cannot make (e.g. devlog5-radar-stance-slots, two lock stances side by side)
with a stdlib PNG codec, generates the section icons, and reports which shots
have no capture example yet. Commit the resulting PNGs (they are content, like
banner.png). Run python3 scripts/gen-web-screenshots.py --self-test to check
the PNG codec (decode/resize/compose) and the report’s classification rules in
isolation.
What is still missing
python3 scripts/gen-web-screenshots.py --report
Scans web/src/** for referenced assets/<name> images, diffs them against the
manifest and the shipped assets, and prints each gap with an owner class:
| 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 assets/base/gltf/parts/<ship>/, which
is byte-reproducible from its recipe.
Eyeballing the site
npm run ci proves the bundle compiles and the theme tokens are in sync; it
proves nothing about how a page LOOKS. For any styling, layout or readability
change, capture the pages and look at them:
nix develop -c scripts/shoot-web-pages.sh target/web-shots
It builds web/, serves web/dist on a free port and drives headless chromium
over the six page kinds (landing, news index, a news post, tutorial, wiki index,
a wiki page with code + a table + mermaid) at desktop and mobile widths,
writing <kind>-<width>.png plus a manifest.txt naming the commit. For a
before/after, run it once per commit into two dirs and compare the matching
pairs at identical crop and scale - a comparison at two different zooms shows
you the resize, not the change.
The theme is shared with the game
web/src/style.css and crates/nova_ui/src/theme.rs both mirror the :root
block of web/design/nova_ui_rework_poc.html - the NOVA OS palette and its
control vocabulary. That PoC is the single source: change it first, then both
consumers.
The PoC ships two skins and the site wears only one. Everything the site draws
comes from the PHOSPHOR skin (the PoC’s body[data-skin="phosphor"] widget
zoo): flat translucent green fills, 1px phosphor hairlines, 2px corners on
controls, glow instead of bevel, and a solid --phosphor inversion for the
primary state. The light-3D HARDWARE vocabulary (--face, --rim,
--undercut, --well) stays in :root only to keep the mirror exact - it must
be consumed nowhere.
web/tests/theme.test.ts (part of npm test, and so of npm run ci) parses
the PoC and style.css and fails if the site’s tokens go missing or drift in
value, if the phosphor vocabulary stops being consumed, or if any hardware
material token is read outside :root.
Run verification (probe)
The run-harness is two crates split at the process boundary: nova_probe
(crates/nova_probe/) links into the example and collects the evidence, and
nova_probe_cli (crates/nova_probe_cli/) is the host side. Together they drive an autopilot
example, records what happened (correctness) and what it cost (performance),
and assembles one reviewable report. The POST-FEATURE CHECK - “did my change
break behavior or perf?” - is one command:
cargo run --features debug probe run system_player_path # clean + frame time + trace -> report
cargo run --features debug probe run system_player_path --correctness-only # clean behavioral evidence only
cargo run --features debug probe run system_player_path --samply # + named flamegraph
cargo run --features debug probe run system_player_path --baseline probe-runs # FPS deltas vs nearest prior commit
cargo run --features debug probe run system_player_path --repeat 5 # gated repeat set -> a usable worst frame
cargo run --features debug probe run system_player_path,system_scenario_grammar # comma list -> aggregate index
cargo run --features debug probe run systems # a whole category
cargo run --features debug probe run --all # the whole fleet
cargo run --features debug probe scenario broadside # a SCENARIO by id, no example involved
cargo run --features debug probe scenario assets/base/scenarios/broadside.content.ron # ... or by file
It runs the example headless (throwaway Xvfb; --display :0 to reuse yours -
and note that a software X server is not free, see
Measuring performance),
captures the run timeline + continuous invariants + the log into
probe-runs/<short-commit>/<example>/ by default (or
<out-base>/<short-commit>/<example>/ with --out <out-base>), optionally
adds the profiled and samply passes (separate builds - tracing overhead never
touches the clean numbers), and renders report.html + checks.json with a
provisional OK/WARN/FAIL/NO_DATA/UNPROBEABLE the reviewer confirms. Every
run dir carries a probe-run.json manifest (identity, full git SHA, passes, outcomes); probe report only re-renders dirs that have one. The commit root also gets
index.html, index.json, and probe-all.json, even when the spec names one
example. --correctness-only runs only the clean pass: timeline, invariants,
autopilot assertions, completion, reached-Playing, and log checks remain armed,
while frame-time and traced passes are omitted. CI uses this mode; release
verification uses the full run. Three verbs are the whole surface - run,
scenario and report - and each takes -h/--help, as does the root.
probe scenario takes those same passes to a scenario, with nothing in
between: the SHIPPED GAME BINARY is the program, launched with --scenario <id> or --scenario-file <path.ron> and carrying the probe collectors under
its debug feature. A positional ending in .ron is a loose content file,
registered for that run whether or not it is installed - so a scenario a
contributor or a mod author is still writing is measurable without adding it to
any catalog. Its self:// art resolves against the nearest enclosing
*.bundle.ron folder, the same place the merge would have resolved it. There
is no spec to expand and no aggregate: one subject, one run dir, the same
report.html + checks.json. Measuring a scenario needs NO example, no
[[example]] block and no Rust file - that is the point of the verb.
Every run spec resolves to a list. A single example is just a one-item list;
comma lists, category dir names, and --all expand against the [[example]]
catalog and run sequentially with continue-on-failure. The status index lives
above the example dirs: index.html (one row per example - verdict, measured
n/total, one column per check, duration, a link to its report), index.json
(the machine mirror), and probe-all.json (the re-render gate). The aggregate
verdict is the WORST row; the exit code mirrors it. --all runs the whole
catalog, and a bare probe run errors with the catalog listing rather than
starting a fleet sweep by accident.
Categories take single-digit minutes warm; --all is the pre-release/nightly
sweep (roughly half an hour). --baseline <base> searches <base> for the
nearest previous commit-hash directory in git history, ignoring compatibility
folders such as before, then each example compares against
<base>/<previous-short-commit>/<example>/frametime.csv when present. Without
--baseline, probe searches the same base used by --out, defaulting to
probe-runs.
Probe runs are profile-sandboxed: a run measures a commit, so it must not
depend on your desktop profile. Every native child run is pointed at an empty,
probe-owned profile under its own run dir - profile/mods
(NOVA_MODDING_CACHE_ROOT, the downloaded-mod cache and its installed.mods.ron),
profile/data (XDG_DATA_HOME) and profile/config (XDG_CONFIG_HOME, where
enabled_mods.ron and settings.ron live) - and the tree is wiped at the start
of each run. Without it, a mod cached in a structure an older commit cannot
parse, or a saved enabled-mod set, fails or shifts a run for reasons unrelated
to the code under measurement. Shipped content is untouched: assets/ and
assets/mods.catalog.ron load exactly as they do for a player, only YOUR saved
state is swapped out. To probe your real installed mods, export the variable
yourself - probe preserves any of the three it finds already set, and prints
which ones it left alone:
NOVA_MODDING_CACHE_ROOT=~/.local/share/nova-protocol cargo run --features debug probe run system_player_path
XDG_CACHE_HOME is deliberately NOT redirected (the shader cache lives there,
and throwing it away each run would make FPS numbers incomparable). The XDG
pair is how the dirs crate resolves on Linux, the supported probe host;
nova_probe_cli::native::profile_sandbox has the details.
Frame-time capture, the repeat set and its validity gate, the fixed-step and Xvfb traps, the frame-cost and census breakdowns, the preset sweep and the profiled pass all live on Measuring performance. This section stops at the run and its verdict.
Run timeline (correctness recording)
nova_probe also records WHAT HAPPENED during a run: set
NOVA_PROBE_TIMELINE=<out.jsonl> on any example that adds
nova_probe::nova_timeline() - which NovaProbePlugin does unconditionally,
so that is EVERY cataloged example - and the run appends one JSON object per
line: every GameStates/pause transition, every fired scenario
event with its payload (kills, area enter/exit, locks), every scenario-variable
change (old/new), plus the beats the autopilot script pushes itself via
nova_probe::probe_marker. Entries are flushed as written, so a panicked run
keeps everything up to the panic. Compare runs by ORDER and VALUES, not
timestamps (wall-clock and frame counts vary across hosts):
NOVA_PROBE_TIMELINE=/tmp/run.jsonl NOVA_AUTOPILOT=1 \
cargo run --example system_player_path --features debug
The timeline is native-only (no fs in the browser) and inert without the env var. It is the correctness half of the run-harness whose performance half is Measuring performance; the run report below renders both.
Continuous INVARIANTS ride the same stream: set NOVA_PROBE_INVARIANTS=1 (or
=strict to panic on the first violation) on a wired example and every frame
asserts what the engine guarantees - health within 0..=max and finite,
velocities finite (plus an absurd-speed bound at 10x a ship’s soft
FlightSpeedCap), scenario Number variables finite, registered monotonic
variables never decreasing (opt-in per example: system_player_path registers
target_down/leg, system_scenario_grammar seven counters and latches,
system_outcomes hostile_down), and a total entity-count leak bound. A
monotonic is one-way within a SCENARIO LIFE, not for the process: the memory is
forgotten on ScenarioLoaded, so an example that replays through its loop
point re-seeds its latches without taking a false regression. Violations warn,
land on the timeline as kind: "invariant" entries, and feed the report’s
invariants held check.
The run report (one verdict surface)
run_report assembles a RUN DIRECTORY - whatever the passes above dropped
into it (timeline.jsonl, frametime.csv, trace.json, run.log, each
optional) - into a self-contained report.html plus a machine-readable
checks.json:
cargo run --features debug probe report <run-dir>... [--baseline <old-run-dir>]
Auto checks produce a provisional OK/WARN/FAIL/NO_DATA/UNPROBEABLE (process
exit from the run manifest, run completed, reached Playing, invariants held,
FPS vs baseline as a soft gate, log scan, artifacts loadable); a check whose
capability the example never declared is N/A - “not claimed” - and an
unresolvable one is SKIPPED - “not measured”; neither means “held”.
checks.json pairs the verdict with a measured: n/total figure plus
per-check structured data. A present-but-unloadable artifact degrades that one
artifact to absent and FAILS artifacts_loadable with the reason, rather than
aborting the report the failure would have been visible in.
Zero evidence is NO_DATA (nonzero exit) and a run that graded no declared
capability is UNPROBEABLE (zero exit - the sanctioned no-probe-plugin
opt-out, gated on its smoke checks alone), FPS improvements PASS (only regressions WARN -
frame numbers are host-noisy), a hung run is killed and still produces a
FAILing report, and the report ends with a reviewer checklist: the final
OK/NOT-OK is a human’s or an agent’s call, off checks.json without
parsing HTML.
Versioning and release
- Version:
workspace.package.versionin rootCargo.toml; crates inherit it. nova_info::APP_VERSIONcomes from theAPP_VERSIONenv var viabuild.rs.- Packaging assets (icons, installer, .app) live under
build/.
The nix package
The flake packages the game as well as the development shell, so a player needs neither a checkout nor a toolchain:
nix run github:alexjercan/nova-protocol # build it and play
nix profile install github:alexjercan/nova-protocol
nix build .#default # ./result/bin/nova-protocol
Three outputs, and which one you want depends on what you are doing:
| 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.
It can also be re-run via workflow_dispatch with a version input.
Writing the release news post
Every release cycle gets one News post on the site (/news/, markdown under
web/src/news/). News is the merged devlog + release notes: one post per
FEATURE release (v0.X.0). Patch releases do NOT get
their own post - they fold into the parent feature post’s ## Point releases
section (v0.5.0’s post covers v0.5.1 and v0.5.2). The terse per-version
list stays in CHANGELOG.md; source the post’s content from the cycle’s
CHANGELOG.md sections.
A News post follows the spirit of Factorio’s Friday Facts: a narrative lead,
then a handful of feature-by-feature ## sections written candidly (the
reasoning, the dead-ends, the piece you are proudest of), leaning on screenshots,
and - where a devlog video exists - an optional ## Watch the devlog companion
near the top (the written highlights must stand on their own; the video is an
extra). Do not just restate the terse CHANGELOG.md.
Adding a post touches three places (mirror an existing post such as
web/src/news/0.5.0.md):
- 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).
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 every PR and push tomaster:cargo fmt --check,cargo clippy --workspace --all-targets --features debug -- -D warnings,cargo test --workspace --features debug, then the windowedprobe run --all --correctness-onlysweep 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 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_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_os | NOVA OS logic: the terminal model, shell grammar and app runtime. No bevy UI. |
nova_os_ui | The NOVA OS cockpit monitor the player opens with Tab: CRT terminal UI, forwarded pointer, and the map/ship apps. A peer of the HUD, added by nova_core. |
nova_scenario | Scenario engine: events, filters, actions, variables, world, loader, objects. |
nova_events | Shared game-event kinds + entity identity components (gameplay <-> scenario). |
nova_assets | bevy_asset_loader setup; loads glb/textures/shaders/sounds; owns the mod merge + 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. |
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_os_ui, 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_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_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 | – |
| The HUD (widgets) | crates/nova_hud/src/ | – |
| The NOVA OS monitor / its apps | crates/nova_os_ui/src/ | – |
| 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 |
| 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 |
| 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 + NOVA OS monitor, editor, menu, debug).
The state machines:
GameStates { Loading, MainMenu, Playing }– top-level lifecycle.PauseStates { Unpaused, Paused }– the ESC overlay, nested inPlaying.GameAssetsStates { Loading, Processing, Loaded }– the asset pipeline that gates entry; onOnEnter(Loaded)the app hands off toMainMenu/Playing.
Gameplay systems run an explicit chain configured identically in Update and
FixedUpdate; avian3d physics runs on a fixed timestep in FixedPostUpdate.
The plugin order, exact sets, frame flow, and the Update-vs-FixedUpdate rule
live in Architecture – start there once the shape clicks.
flowchart LR
player["Player / AI input"] --> game["Game crates<br/>(nova_ship + nova_gameplay + nova_core)"]
game --> scenario["nova_scenario<br/>(events / filters / actions)"]
data["Data (RON)<br/>scenarios + mods"] --> assets["nova_assets + nova_modding"]
assets --> game
scenario --> game
game --> screen["Rendered frame + HUD"]
Keeping docs in sync
Nova Protocol documents itself across several surfaces, each aimed at a different reader. None of them updates itself, so a code change is not finished until the docs it invalidates are fixed in the same task. This page is the map: what the surfaces are, what to touch when you change code, and what to do when you cut a release. It is the overview; the detailed, command-level steps live in Building and running under “Versioning and release” and “Writing the release news post”.
The documentation surfaces
| 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 |
| 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? Add a line to
CHANGELOG.mdunder## [Unreleased], in the right subsystem section (Gameplay & Flight, Combat & Weapons, Ships & Sections, Scenarios & Objectives, Modding & Mod Portal, Interface & HUD, Web & Platform, Audio & Visuals, Performance, Fixes, Internals & Tooling). One terse line; tag a format break (breaking). - 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) | |
Ship sections, integrity, typed damage, ammo (nova_ship/sections, nova_gameplay/integrity) | 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_*) | 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, controller, camera (nova_ship/input, camera) | flight-autopilot.md, keybinds.md | Architecture | CHANGELOG |
Targeting, radar, weapons, turrets, torpedoes (nova_ship targeting/sections, nova_hud) | targeting-radar.md, combat-weapons.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 |
Modding data format, bundles, catalog, local cache (nova_mod_format, nova_modding) | modding.md | /create/mod-files/, /create/base-content/ (overlay rules, dep://base) | CHANGELOG (breaking?) |
The ship content kind: what a hull IS vs what a spawn is (nova_scenario/objects/ship.rs, nova_authoring/base_content/ships) | sections.md | /create/ships/, /create/objects/ (the Spaceship spawn), /create/mod-files/, /create/reference/ | CHANGELOG (breaking?) |
Mod portal + generator (scripts/gen-portal.py, nova_modding) | modding.md | /create/publish-a-mod/ | CHANGELOG |
Menus, editor, UI (nova_menu, nova_editor, nova_ui) | hud.md, sections.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 |
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 | ||
Local dev servers (scripts/serve-web.sh, scripts/serve-mods.sh, scripts/preview-web.sh, web/webpack.config.js, Trunk.toml) | Building and running (“Local web preview”), /create/publish-a-mod/ (“Preview the repository portal”) | README.md quick start + scripts table |
“Check” means re-derive, not grep
A name-level sweep - “does the page mention the new crate?” - passes while every claim BETWEEN the names goes stale: a dependency graph drawing a crate with the wrong consumers, a crate map missing four members, a command attributed to the crate it moved out of. Someone then reads one of those and answers a question wrong, which is the failure the sweep was supposed to prevent.
So when a row above says “check”, it means: re-derive every crate name, module
path, command, symbol and dependency direction the page asserts, against the
current tree. A page that names the new thing can still describe the old
one. Grep the page’s own file:line and symbol claims and open each one - a
type that no longer exists is an unambiguous defect and needs no judgement.
Two structural traps this map cannot catch by itself:
- The map’s own row keys are crate/dir names, so a structural refactor invalidates the MAP too - that is what the first row is for.
- A lane-per-change epic has no lane whose job is the cross-cutting sweep.
Give the sweep its own step (or lane) whenever
crates/*changes shape.
When you cut a release
The full command-level checklist is in Building and running -> Versioning and release. At the doc level, a release means:
-
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 page: add it to the manifest
web/src/docs-manifest.js, which drives the page list, the sidebar, search and see-also (web/webpack.config.jsreads it). - 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. The Settings modal (audio volume, graphics preset, interface skin, and the Controls tab that REBINDS every action in nova_input, one binding group at a time) is shared by both entry points and persisted cross-platform in settings_store (RON file / localStorage), keybind overrides included. |
nova_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 generic SFX engine nova_menu and nova_os_ui also use), juice, shake, settings (MasterVolume/GraphicsQuality + apply systems), mesh (the procedural TriangleMeshBuilder, plus the SignedField an asteroid is meshed from and carved in - nothing here takes a finished mesh apart), transform, relations, beacon, objectives (the GameObjectives list, its panel and the conveyance tags), lifetime (TempEntity/DespawnEntity), cooldown, plugin. Also owns GameStates, PauseStates, and the GameMode resource. Knows nothing about a ship. |
nova_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_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 render-gated, and the crate places NovaHudSystems between the section and camera sets itself. |
nova_os | NOVA OS logic with no UI in it: the terminal model (terminal), the shell command language and typo suggestions (shell), and the app runtime seam (app). |
nova_os_ui | The NOVA OS cockpit monitor the player opens with Tab: the CRT casing and shader, the terminal nodes and keyboard/pointer systems (terminal), and the two apps that run on it - map (schematic local space) and ship (schematic player ship). A PEER of the flight HUD, not one of its widgets: nova_core adds it, and nothing in nova_hud reaches into it (it reads NovaHudAssets and NovaHudSystems, so it sits ABOVE nova_hud). |
nova_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_events | Game event kinds and entity identity components, shared between gameplay and scenario. |
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), 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_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 theme palette/metrics (theme::*), the UiSkin visual-language switch (skin), the themed widgets (widget: button, slider, segmented control, list rows, panel chrome), screen-level composition (screen: scrollable viewports and the list-beside-details layout the menu screens and the NOVA OS drawer share), the flight-HUD chip language (hud), player-facing unit formatting (units), the shared typeface (font), the generic status_bar and the keyboard-ownership arbiter (input_mode: one app-global InputMode resolved from per-frame claims, with InputModeSystems as the ordering handle every keyboard consumer gates behind). Consumed by nova_gameplay, nova_hud, nova_os_ui, nova_menu, nova_editor and nova_assets. |
nova_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_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 --> osui["nova_os_ui"]
core --> scenario["nova_scenario"]
core --> assets["nova_assets"]
ship --> gameplay
ship --> events["nova_events"]
hud --> ship
hud --> gameplay
hud --> ui["nova_ui"]
osui --> hud
osui --> ship
osui --> gameplay
osui --> os["nova_os"]
osui --> ui
menu --> osui
menu --> ui
editor --> ship
editor --> ui
gameplay --> events
gameplay --> ui
ship --> input["nova_input"]
hud --> input
osui --> input
menu --> input
editor --> input
scenario --> input
scenario --> events
scenario --> gameplay
scenario --> ship
scenario --> hud
assets --> modding["nova_modding"]
assets --> scenario
modding --> modfmt["nova_mod_format"]
core --> debug["nova_debug"]
core --> info["nova_info"]
The graph is curated for readability, not exhaustive: nova_core and nova_menu
depend on nearly every crate below them, and edges implied by the layering (for
example nova_menu -> nova_ship) are pruned. The authoritative dependency list
for any crate is its Cargo.toml. Two edges people guess wrong:
nova_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_os_ui,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.
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_os_ui- consumers of the ship and of gameplay, above both.nova_os_uiis abovenova_hudin turn: it orders itself againstNovaHudSystems.nova_core- wiring only.
App assembly
AppBuilder (in crates/nova_core/src/lib.rs) is the single place the app is wired:
#![allow(unused)]
fn main() {
AppBuilder::new() // Bevy DefaultPlugins + window/log/asset/render setup
.with_game_plugins(my_plugin) // optional: your own systems/observers
.build() // adds the plugin stack, returns App
}
AppBuilder::headless() is the same builder with no wgpu device, no window, no
winit event loop and none of the visual game plugins. Rendering is fixed by the
CONSTRUCTOR rather than by a setter, because DefaultPlugins bakes the wgpu and
window settings the moment the builder starts - a later setter could not reach
them. It is one switch and not two because the halves cannot be separated:
bevy_hanabi panics without a render sub-app, so dropping the device forces
dropping the plugins that need it.
build() inits GameStates + PauseStates, then adds, in order:
EnhancedInputPlugin, GameAssetsPlugin, LoadingScreenPlugin,
NovaGameplayPlugin, NovaShipPlugin (the ship orders its sets inside
gameplay’s SpaceshipSystems brackets, so it comes after), NovaScenarioPlugin,
then - render-gated, a headless harness run draws neither - NovaHudPlugin and
NovaOsUiPlugin (HUD first: the monitor orders itself against
NovaHudSystems), then NovaEditorPlugin and NovaMenuPlugin (both only when
no custom game plugins were supplied - the menu fronts the default app and
nothing else, so an example that brings its own game plugins goes straight
Loading -> Playing), and finally DebugPlugin under the debug feature. On
OnEnter(GameAssetsStates::Loaded) it hands off to MainMenu (or straight to
Playing when the menu is off) and spawns the status UI.
NovaGameplayPlugin pulls in avian3d PhysicsPlugins (zero gravity, projectile
collision hooks), bevy_rand, bevy_hanabi particles (on wasm via the WebGPU
backend), NovaUiPlugin (render-gated), the transform/lifetime/mesh rigs, and
the shared gameplay sub-plugins: integrity, damage, gravity, relations, audio,
juice, settings. The ship stack (input, sections, flight, camera, physics) is
NovaShipPlugin’s, and the HUD is NovaHudPlugin’s - both added by
nova_core, not by gameplay.
States
GameStates { Loading, MainMenu, Playing }(nova_gameplay) - top-level lifecycle.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.PauseStates { Unpaused, Paused, NovaOs }- the freeze axis.Pausedis the ESC pause overlay;NovaOsis the Tab ship-computer takeover (same clock freeze, cursor freed, no pause menu). Both frozen variants enter only fromUnpausedand exit back to it, never into each other.nova_gameplayowns the enum and gates the spaceship sets;nova_menuowns the toggle, the overlay UI, and the clock freeze (Time<Virtual>+Time<Physics>). Only meaningful insidePlaying; leavingPlayingresets it.GameAssetsStates { Loading, Processing, Loaded }(nova_assets) - asset pipeline. Scenario setup hooksOnEnter(GameAssetsStates::Loaded)- seeexamples/systems/system_scenario_grammar.rs.
The top-level lifecycle, the pause overlay nested inside Playing, and the asset
pipeline that gates entry:
stateDiagram-v2
state "GameStates" as GS {
[*] --> Loading
Loading --> MainMenu: menu app
Loading --> Playing: custom game plugins
MainMenu --> Playing: New Game / Sandbox
state "Playing" as Playing {
[*] --> Unpaused
Unpaused --> Paused: ESC
Paused --> Unpaused: ESC
Unpaused --> NovaOs: Tab
NovaOs --> Unpaused: Tab
}
}
state "GameAssetsStates" as AS {
[*] --> AsLoading: Loading
AsLoading --> Processing
Processing --> Loaded
}
AS --> GS: OnEnter(Loaded) hands off to MainMenu / Playing
Leaving Playing resets PauseStates back to Unpaused.
Frame flow
Gameplay systems run in an explicit chain, configured identically in Update and
FixedUpdate. nova_ship::NovaShipPlugin declares the brackets and the ship
sets; nova_hud slots NovaHudSystems into the gap itself:
SpaceshipSystems::First -> SpaceshipInputSystems -> SpaceshipSectionSystems
-> NovaHudSystems -> NovaCameraSystems -> SpaceshipSystems::Last
- Physics (avian3d) runs in
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.
What breaks if a system lands in the wrong schedule – worked example: a
FixedUpdate system reading GlobalTransform. thruster_impulse_system used to
apply its impulse at the thruster child’s GlobalTransform, i.e. the previous
frame’s eased pose, up to ~2 ticks of ship motion behind the raw physics it was
pushing, while taking thrust DIRECTION from the raw Rotation – mixing both
clocks in one impulse. The application-point error is proportional to velocity;
at speed a COM-centered engine developed an uncompensated lever arm the throttle
balancer could not see, and the measured failure was a zero-true-torque lateral
engine spinning the hull to 7.1 rad/s in 15 frames (0 rad/s after reading the raw
pose). The fix (thruster_section.rs, see the comment above apply_linear_impulse_at_point)
composes both application point and thrust direction from the root’s raw
Position/Rotation. The same footgun produced the bullet-spew, HUD-jitter and
crosshair-twitch bugs in that family; all were error proportional to velocity,
which is why they only showed at high speed.
Worked example for the third rule – a DRAWN value that gates a fixed-step
branch. A turret’s aim chain solved the intercept, drove the hinges and wrote
the joint pose in PostUpdate, which is where a thing you can see belongs. But
shoot_spawn_projectile runs in FixedUpdate and asks, per tick, whether the
muzzle is inside a 0.92 deg cone of that aim point. The barrel’s pose was
therefore a staircase with one step per FRAME, its tracking residual scaled with
the frame period, and the cone is narrower than the residual’s span: measured on
stress_point_defense, the battery held its trigger 9.7% of the time at 20 fps
and 60.6% at 106 fps – same scene, same seed, a six-fold difference in how
strong point defence was. Moving the whole chain (solve, hinge controller, joint
sync) onto the fixed clock made the trigger duty 0.811-0.817 across the same
span. Nothing about the aim chain looked like sim work; the tell was that a
FixedUpdate system branched on its output.
Cross-system communication goes through events and observers (Bevy On<...>
observers, e.g. the integrity/destruction chain) rather than direct calls. Prefer
adding an event/observer over coupling two systems.
Assets
assets/ is runtime-only - everything the game actually loads: shaders/
(.wgsl), icons/, sounds/ (UI chrome: menu clicks + objective chimes),
and the base//mods/ data (.ron). The base game’s own art and world audio
live UNDER assets/base/ (exported gltf/ models .glb, textures/,
sounds/ .wav world cues, banner.png), referenced by base content with
self:// and by mods with dep://base/<path>. It is the whole directory the
web (Trunk copy-dir) and native (release.yaml) builds ship, so non-runtime
files must not live here. The Blender SOURCES the gltf/ models are exported
from live OUT of the shipped tree, in top-level art/blender/, because nothing
loads them at runtime. The built-in sections, ships, styles, scenarios and
campaigns ARE data: the Rust builders under
crates/nova_authoring/src/base_content/ (sections/, ships/, styles.rs,
scenarios/, campaigns.rs, assets.rs) are the single source, and
cargo run content gen serializes them to the
committed assets/base/**/*.content.ron the game loads like any other bundle.
Never hand-edit the generated files; edit the builders and re-run gen.
Find it in the code
- App assembly, plugin order:
AppBuilder-crates/nova_core/src/lib.rs; game binary and CLI flags -src/main.rs. - States:
GameStates,PauseStates,GameMode-crates/nova_gameplay/src/lib.rs; ESC overlay and clock freeze -crates/nova_menu/src/pause.rs. - Frame-flow sets:
SpaceshipSystems-crates/nova_gameplay/src/plugin.rs; chained 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. - API detail:
cargo doc --open -p nova_core(any crate from the map works).
Spaceship sections and integrity
To add a new section kind, follow the guide Add a ship section.
Ships are assembled from modular sections. Each section is a child entity of the ship root with its own collider, mass, and health, and contributes one behavior (structure, thrust, steering, guns). The integrity system tracks how sections connect and handles damage, disabling, and cascading destruction.
Sections (nova_ship::sections)
A section is a SectionConfig { base: BaseSectionConfig, kind: SectionKind }.
BaseSectionConfig is shared by all kinds: id, name, description,
health, optional impact_sound / destroy_sound, optional collider,
structural link_points, hide_in_editor, and damage_effects - the authored
list of looks this section wears as it is damaged (see Damage is two
readings).
SectionKind variants (one module per kind under crates/nova_ship/src/sections/;
turret_section/ and torpedo_section/ are directories, not single files):
| 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). Also grants flight verbs (STOP/GOTO/ORBIT maneuvers plus LOCK targeting and RCS fine-translation). A ship needs one to be drivable; several SHARE one attitude loop. |
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 ammo_capacity. |
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 ammo_capacity. The TYPE is the run-in - how fast and how evasively; everything else on the config is the tube. |
GameSections(Vec<SectionConfig>) is the resource of section blueprints.
Generic prototypes are authored in
crates/nova_authoring/src/base_content/sections/standard.rs; semantic craft
parts live under base_content/ships/. Their explicit section_catalog() is
generated into assets/base/sections/base.content.ron by content -- gen and
merged into the resource by
crates/nova_assets/src/merge.rs. The outer-skin cladding is not a prototype at
all: a ship’s skin is DERIVED from the structure it wraps by nova_ship’s
shell_skin (below), so no id names a plate. Look a
section up with
sections.get_section("basic_thruster_section").
The attitude envelope, and how controllers stack into it
How hard a hull may turn is DERIVED, not authored. AttitudeEnvelope
(crates/nova_ship/src/physics/attitude.rs) is two ceilings and the lower of
them:
alpha_max = min( sum(max_torque) / I , LOAD_LIMIT / (r * METERS_PER_UNIT) )
\___ propulsive ___/ \________ structural ________/
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, in world units.structural_armderives it;BodyRadiusis a scenario-obstacle radius and is not it.LOAD_LIMITandMETERS_PER_UNITare incrates/nova_events/src/scale.rs- one definition each, shared by the physics and by the player-facing formatter innova_ui::units.
The two structural loads are perpendicular components of one acceleration at
the tip, so they add as a VECTOR: alpha^2 + omega^4 <= (LOAD_LIMIT / r_m)^2.
A hull already in a hard turn has spent its margin, and its sustained rate is
sqrt(LOAD_LIMIT / r_m). Past that corner the centripetal load alone is over
the limit and the full ceiling comes back, so an over-spun hull can always shed
rate.
Two consequences to expect rather than debug. A small hull is STRUCTURE-bound, so fitting more computers to it changes nothing at all. A hull that loses sections shortens its arm and turns SHARPER than it did intact.
Every live controller torques the hull in parallel, so a hull with several of
them would multiply both its gains and the applied torque by the section count.
update_controller_stack_tuning
(crates/nova_ship/src/sections/controller_section.rs) prevents that: it runs
first in FixedUpdate (ControllerSectionSystems::SyncStack), derives ONE
ship-level attitude loop per root, and writes each live controller a share of
it into its PDController. The authored numbers stay put in
ControllerSectionTuning, which is what the pass re-derives from when a
controller dies. The smallest live steering_lag supplies the stack’s base
response.
The ship-level loop, for n live controllers:
- authority: the envelope above. Torque SUMS across controllers, with no curve and no cap of its own, because the structural ceiling already caps the result. Each controller’s share is its own fraction of the hull’s torque.
- P gain: DIVIDED by
stack_curve(n, 1.5) = 1.5 - 0.5 / n, which increases the effective steering lag so the stack brakes earlier and lands on the commanded attitude instead of sailing past it. - D gain: held at exactly one fastest computer’s worth. This is not tuning:
kd * 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.
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).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, an optional
collapse_threshold (below), a skin flag (the
derived cladding), and a list of
SpaceshipSectionConfig, each placing one section at a position + rotation
relative to the ship root (world units), with a source (Inline /
Prototype) and optional modifications. The player
config carries the input mapping (section id -> key/gamepad bindings) plus
speed_cap and infinite_ammo; the AI config carries patrol/orbit/leash/engage_delay.
Spawning: the base scenario bundle gives the root RigidBody::Dynamic; the
spaceship object adds SpaceshipRootMarker, and an observer
(insert_spaceship_sections) spawns each section as a direct child. Every
section gets SectionMarker, its Collider (the authored collider shape, or
a unit cube by default), SectionLinkPoints, ConnectedTo, and Health (base_section in
sections/base_section.rs), so the ship is one rigid body whose child colliders
each carry their own health.
See the semantic Racer, CargoA, and CargoB builders under
crates/nova_authoring/src/base_content/ships/ for complete generated examples.
The editor (crates/nova_editor) assembles ships interactively using
preview_section, which has no health or rigid body and never enters the
damage pipeline.
The derived skin
skin: true on a ship’s hull asks the game to CLAD it. Nothing authors a plate:
spawn_ship_skin (sections/shell_skin.rs) reads the finished section batch on
the same Added<SectionLinkPoints> edge the integrity graph is built off,
buckets the sections into cells (the lattice is read off the sections, so a hull
mirrored about its centreline is not cut in two), and derives one plate per cell
of outer surface from the eight boundary samples that cell shares with its
neighbours. The same structure always gives the same skin.
- A plate is a
SectionFixture(sections/fixture.rs):Collider,Health, density 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;
despawn_dead_fixturestakes a dead plate away and nothing puts it back. ShipSkinPlugin { render }is split at the render line, not at the look line: the derivation and the sweep are gameplay and run headless, 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 semantic parts (the racer, the haulers) are not.
The editor’s live preview
The build view clads the ship being ASSEMBLED, and it re-derives rather than
spawning once: sync_editor_skin (nova_editor/src/skin.rs) runs after
sync_placement_ghost, hashes the structure it is about to read, and respawns
the whole skin when that hash moves. Nothing is patched and nothing is
diffed - the derivation is a pure function, so throwing the plates away and
asking again is both the simplest answer and the one that cannot drift. On a
384-plate ship a reflow costs about 2 ms and an unchanged frame about 0.1 ms;
a real build is an order of magnitude smaller than that, and the ghost only
travels in whole cells (placement mates sockets), so dragging a part does not
re-derive per frame.
Two things it does differently from the spawner:
- The part UNDER THE POINTER is structure, while its placement is legal. That is the feature: a hull is dragged about under the skin and the cladding closes around it before the click. A REFUSED ghost contributes nothing - it will not be built, so cladding it would draw a ship that cannot exist.
- A preview plate is DISPLAY ONLY:
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 SpaceshipConfig the scenario spawns,
so what you see in the editor is what you fly.
The plate vocabulary and skin styles
The derivation works out far more than it keeps. read_plates
(sections/skin_reading.rs) is a SECOND PASS over the finished plates that reads
it back out as a PlateReading each: which way the plate faces, what its top is
shaped like (Flat / Step / Ridge / Peak / Bevel / Brink / Spur),
which way it falls away, how enclosed its cell is, how long the run of like plate
through it is and which way that run points, how far it is from the end of that
run, how much of its cell it fills, how deep the structure under it goes, and how
close the mouth of a fitting is. The plates are the whole input - their cells are
the clad set, cell - anchor is the face each shows - so a reading cannot drift
from the skin it describes. It costs about 0.6 ms on a 384-plate ship, against
1.6 ms to derive the skin itself.
The FALLING PLATE is three reliefs and not one, which matters because it is four
fifths of every ship. A corner sample dies to the cell floor for exactly one
reason - open space stands at it - so counting the dead corners says how many
ways a plate falls: one corner is a Bevel (a panel with a corner taken off),
two on one side is a Brink (the straight edge of a hull), and anything more is
a Spur (a tip, an outer corner, a saddle). Summing those corner directions and
turning them by the plate’s own rotation gives PlateReading::fall, the
OUTBOARD direction - a cardinal on a Brink, a diagonal on a corner, and zero
where a plate falls two ways and cancels. It is the second alignment axis: a
piece turned to along lies down an edge, and one turned to fall leans out
over it.
A ShipStyleConfig (sections/skin_style.rs) is CONTENT resolved by id out of
GameStyles, exactly as a section prototype resolves out of GameSections. It
carries a material per surface role and a list of decoration fixtures, each with
a model AssetRef and a ScatterRule written in the vocabulary above. The mod
merge routes Content::Style into GameStyles with the same last-wins overlay
every other kind gets, so a mod restyles a base look by declaring its id.
scatter_decor (sections/skin_decor.rs) turns plates plus readings plus a
style into placements. It takes the READINGS, not the structure, so the scatter
cannot reach past the vocabulary into the derivation. Two properties are
load-bearing:
- DETERMINISM. There is no RNG. A plate’s claim is a hand-written FNV-1a hash of
its cell, its out face and the fixture’s id - hand-written because
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 never displaces another rule’s piece, so priority still means what it says.
With chance: 0.0 the share picks nothing and the rule is purely “one piece per
block”, which is a density that reads the same at any hull size.
A decoration is a SectionFixture like a plate, and a child of the PLATE, one
level further out - so a plate shot off takes its greebles, and
damage_cracks’s owning_section walk stops at the first fixture it meets
whichever of the two it started under. The base game generates its greeble models from committed JSON recipes
(scripts/gen-greebles.py), and the mod-facing format is documented in
Ship skin styles.
What a hull actually OFFERS is worth knowing before writing a rule, and a GENERATED hull and a HAND-BUILT one offer almost opposite things. Measured, per ship:
| 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
eight 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 mass-times-velocity impact damage.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 eight) - 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) - what a destroyed body DOES, and it detaches. 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 and a spin, and
despawns on a timer (PIECE_LIFETIME_SECS). A death moves entities and clones
one collider handle, which is why the module has no fragment budget and no
spawn queue - there is nothing left to ration.
Four properties follow from that, and each is load-bearing:
- Destructibility is SEMANTIC. It is
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.
There is NO FALLBACK for a body with no collider: it leaves nothing behind and
logs an error, and system_destruction_finale asserts that branch never runs
on shipped content. A refusal rather than a stand-in, because a stand-in looks
like SOMETHING - a body that had silently failed to come apart would be
indistinguishable from one that came apart badly.
Editor placement mates the same sockets, so the editor cannot build a ship the
graph would reject. snap_placement (nova_ship::sections::link_points) poses a
part from one mate: the two sockets become coincident and their normals opposed,
which leaves only the ROLL about that axis free - the builder’s choice, alongside
which of the part’s own sockets does the mating.
That roll has a defined ZERO, and it is what makes one part usable on every
other part. Each socket carries an implied up vector, link_point_up(normal):
ship up (+Y) projected onto the socket’s plane, or forward (+Z) on the sockets
that face +-Y. It is DERIVED from the normal rather than authored, so two parts
that never met agree on it without anyone writing a second vector, and
snap_placement mates the two socket FRAMES rather than just the two normals.
Aligning normals alone leaves the roll to whichever axis a shortest-arc rotation
happened to sweep about - and for a socket facing exactly opposite the part’s
own, to an arbitrary perpendicular.
The other half is the normals themselves. An authoring tool that derives a
socket from part GEOMETRY gets whatever angle the neighbour happened to sit at,
so cardinal_axis snaps the derived normal to the nearest axis. It is
antisymmetric (cardinal_axis(-d) == -cardinal_axis(d)), so both ends of one
authored edge stay exactly opposed and no existing mate is lost. Without it the
cargob’s pod faced its fuselage 36 degrees off -X and anything mated onto that
socket arrived tilted by exactly that much - which is what made parts look like
they only fit the craft they were cut from.
box_link_points(size) is the general face-socket helper
(unit_cube_link_points is box_link_points(Vec3::ONE)). A part authored at its
own size mates against a part of any other size, because the sockets meet face to
face and the roll comes from the axis alone; pdc_kinetic_turret_section (and
its pdc_pierce_turret_section twin, the same gun with a different round) is the
shipped example - one compact mount that fits every hull, replacing ten per-craft
copies of the same gun.
candidate_link_point_mates is
the same pairing WITHOUT the ambiguity and connectivity gates, because a ship
under assembly is legitimately disconnected; the editor uses it to see which
sockets are taken and to refuse a placement that would leave one with two
suitors. Collider bounds enter only as the overlap refusal, under the ship lint’s
rule: interpenetration is allowed exactly where a mate says the interface is
intentional.
scripts/cut-obj-into-parts.py proposes candidates for freshly cut parts: two
parts whose bounds meet at a seam and overlap across it get one socket each at
the centre of that shared face, written into the part manifest as link_points.
A recipe part can author its own list instead (in ship space, like every other
recipe coordinate), which replaces the generated one. They are candidates for a
human to judge - shipped gameplay sockets stay hand-authored in nova_authoring.
Damage flow:
- 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 1 u/s kick away from the
cut. They are unsigned debris, not ships: no allegiance, control, defeat event,
or scenario identity.
A ship is disabled progressively, so a collapsing ship can keep shooting for a
few frames; its weapons stop as their own sections go. That also means the
unified defeat edge usually comes from neutralize.rs partway through the
peel, and the root’s later destruction fires only OnDestroyed - DefeatedMarker
is what keeps OnDefeated to exactly one.
Structural collapse is a MATERIAL test and stands apart from neutralization
(neutralize.rs), which is a CAPABILITY test: a ship can be out of the fight
with a sound hull (a derelict to board, salvage or let limp away), and a ship
can collapse while its guns still work.
The cascade a single section walks through:
flowchart TD
A[Section takes damage] --> B[Integrity drops]
B --> C{Zero health?}
C -->|No| A
C -->|Yes| F[Destroyed]
F --> G[Pruned from neighbors]
G --> H{Graph still connected?}
H -->|No| P[Detached components become wreck bodies]
F --> I[Root current re-aggregated over a pinned max]
I --> J{At or below the collapse threshold?}
J -->|Yes| K[Every standing section disabled]
K --> Q{Leaf?}
Q -->|Yes| F
Q -->|No| E[Inactive until pruning makes it a leaf]
K --> L{Nothing destroyed this tick?}
L -->|Stalled| M[Destroy the most leaf-like anyway]
M --> G
I --> N{No sections left?}
N -->|Yes| O[Ship dead]
Damage is two readings
Health decides when something dies. It cannot decide what the wreck LOOKS like, because a pool is one number for a whole body and the only geometry one number can drive is geometry that changes everywhere at once. So there are two readings taken off a hit, and neither is a look of its own.
DamageLevel(f32) (integrity/erosion.rs) - 0.0 pristine to 1.0 destroyed,
derived from the entity’s OWN Health every time health moves. Read it, never
write it. Because it is a function of health rather than an accumulator beside
it, a body at half health always looks the same amount of wrecked, a reload
restores the look for free, and a scripted destroy grades exactly like a shot.
Derived per entity and not per aggregate: a skin plate is HealthIsolated, so a
stripped plate reads as stripped while the hull under it still reads as
untouched.
DamageMarks(Vec<DamageMark>) (integrity/carve.rs) - where the hits
LANDED, each a sphere { at, radius } in the LOCAL frame of the body carrying
the list. A hit is recorded on the nearest ancestor carrying DamageMarks, never
on whatever collider it met. That is what makes a carve continuous: a ship’s
plates each derive from the same list, so two plates sharing a boundary compute
the same depression at it and a crater crosses the seam instead of stopping at
it.
What material costs
DAMAGE_PER_UNIT_VOLUME is 8.0 hit points per cubic world unit. It is the
whole coupling between what a weapon costs and what it looks like it did, and it
is ABSOLUTE: the same round makes the same hole in a pebble and in a planetoid,
because the hole is what the round’s energy is worth. Pricing a crater against
the body it landed on is the other design, and it makes a big rock unshootable
and a small one vanish on contact.
mark_radius(amount) is therefore (amount / 8.0 * 3 / (2 * pi))^(1/3) - a
HEMISPHERE, because a hit lands ON a surface. The shipped kinetic PDC round
(4.0 damage) carves 0.62 units.
A mark is priced by what the hit ABSORBED, never by what it asked for
(absorbed_by): the first Health at or above the hit clamps it, a node already
spent pays nothing, and a chain with no pool at all spends the whole hit in
material. Without the clamp a slug that crosses a plate would be charged for the
plate and then charged again, in full, for the hull behind it.
The merge, and why a hole follows the aim
Sustained fire has to dig ONE hole rather than two dozen dents, and that job has a SIZE - the width of the hole the last round made - which is why it is capped in world units and not proportionally.
MARK_MIN_RADIUS0.15: below this a sphere cannot reach a boundary sample of the cell it lands in, so it would cost a budget slot and change nothing. Grazing fire should crack, which is the level’s job.MERGE_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, 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.12u) - kinematic, no collider, TempEntity(2.5) - and
hold identical values in two SEPARATE entries, so giving a penetrator its own
debris is editing a number rather than splitting a branch. Explosive throws nothing: a
warhead’s fireball already covers the frames the geometry changes in, the crater
is permanent evidence afterwards, and a cut that severs throws real geometry
anyway. Shards are born INSIDE the body they came off, so a dynamic body with a
collider would spawn interpenetrating and the solver would shove the two apart -
a ship kicking itself sideways every time it was shot. An event rather than a
direct spawn, so a mod that wants a puff or nothing at all replaces the observer
instead of patching the carve.
Real geometry leaves a body only where a carve actually SEVERED it, and only the
body being cut knows that. chunk.rs is what a severed piece spawns through;
CHUNK_MIN_VOLUME (1.0 cubic unit) is the floor under which a crumb goes out as
dust instead. The asteroid is the only body that takes this path - see
Scenario engine.
The authored looks
WHICH looks a section wears is content, not engine. A section authors
DamageEffects, a list of DamageEffect, and damage_effects.rs turns each
variant into exactly one component. Nothing else translates, and no effect system
reads the list - each reads only its own component. So the authored list is the
CONTENT vocabulary, the components are the RUNTIME vocabulary, and a Rust mod
that wants a look nobody authored inserts its own component and touches neither.
| 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 ship computer’s section
LABEL (nova_os_ui reads it for codes, glyphs and descriptions); nothing in the
damage path branches on it.
Turret bullets are given a near-zero physical mass (NEUTRALIZED_BULLET_MASS)
so the impact path’s mass-times-velocity damage
(on_impact_collision_deal_damage, integrity/core.rs) is negligible and the
authored amount is the only weapon damage. Torpedoes detonate a NovaBlast.
damage.rs computes linear falloff from each collider’s world centre. For a
target it then walks the centre ray through closer live ship sections: a
survivor stops pressure, while a destroyed section transmits 65 percent. All
blasts collected in one fixed tick read one pre-damage health snapshot. Health is
charged per COLLIDER; the crater is cut once per BODY (see
Damage is two readings).
The torpedo fuze
CONTACT_FUZE is 3.0 units to the target’s SKIN, not to its centre of mass. A
torpedo holding a locked ENTITY projects its position onto the colliders in that
body’s own RigidBodyColliders list. The projection is solid, so a nose already
inside the hull reads zero, and reading only the locked body’s list means a
torpedo threading a formation cannot fuze on the wrong ship. The three-unit
margin clears the torpedo’s own body and both bodies’ motion through the next
fixed step. contact_reach(speed, dt) = CONTACT_FUZE.max(speed * dt) widens it
further for a fast torpedo.
The old fuze was half the blast radius measured to the centre of mass. It had three consequences and no upside: a torpedo always stood off exactly half a blast radius and so always delivered exactly half its rated pressure; against a rock the crater was cut in vacuum beside the surface, because a rock’s centre is buried under twelve units of solid; and nothing in the game had a contact fuze at all.
That fallback survives for the one case with nothing to touch: a torpedo with a
target POSITION but no entity (a scripted launch, or one whose target died in
flight) still fuzes at blast.radius * 0.5. A torpedo launched with no lock
never receives a target position at all, so it cannot detonate - it flies its
lifetime, deals a contact ding and is deleted, and the bay still spends the
round.
Note weave_fade is measured off the BLAST RADIUS and not off the fuze: full
weave beyond three blast radii, linearly to zero at half a blast radius. The
terminal sprint has to start where the corkscrew stops helping, out at
point-defense range, not where the warhead finally fires.
Closing speed
Both BULLET types are speed-driven, and the term is computed at the hit, not at
the muzzle: closing_speed(round_velocity, target_velocity) projects the same
relative velocity on_impact_collision_deal_damage uses onto the round’s own
line of flight (projecting onto the line BETWEEN the bodies is unusable - at
contact they are touching, so that direction is noise). Both curves are the
speed ratio against REFERENCE_CLOSING_SPEED (100 u/s, the shipped PDC’s
muzzle_speed), clamped:
kinetic_damage_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 andMAX_PIERCE_LAYERS(6) the backstop under it, because cheap plating alone would not bound the chain.
A target with no Health on the hit collider (an asteroid, a planetoid, a pool
that lives on an ancestor) has no thickness to price and nothing provably
destroyed, so it is a wall to both types at any speed. Nothing in the rule knows
what it hit, so destructible cover needs no special case. Torpedoes do not use
it - they detonate on a contact fuze.
The carve reads the same absent pool and draws the OPPOSITE conclusion, and the
pair is easy to misremember. absorbed_by walks the same ChildOf chain: no
pool anywhere up it means the whole hit is spent in MATERIAL. That is the
asteroid rule, not a fallback - a rock’s remaining solid is its durability, and
clamping against a pool it does not have would stop rocks carving at all.
One avian trap the hit callsite has to handle: CollisionStart is raised once
per EVENT-ENABLED collider, so a contact with events on both sides arrives
twice with collider1/collider2 swapped. An asymmetric rule must act on one
ordering only (resolve_bullet_hit keys on the round being named first;
on_nova_blast_collision on the blast being body1), or it pays out twice
per contact. A symmetric rule - ram damage - wants both.
Ammo
SectionAmmo(sections/ammo.rs): optional magazine on a weapon section. Absent = unlimited fire;ammo_capacityin the turret/torpedo config opts in. The playerinfinite_ammoflag builds that ship’s weapons without magazines, but only under thedebugfeature: a shipped build logs a warning and keeps the authored magazines, so unlimited fire is a dev cheat, never a player state.SectionReload(sections/ammo.rs): optional idle batch reload on a magazine, from the turret/torpedo configreload: Some((delay, amount)). Every successful shot resets progress; every completed quiet delay restores one batch until full. 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.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. - 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. - Derived skin and styles:
ShipSkinPlugin-crates/nova_ship/src/sections/shell_skin.rs;ShipStyleConfig-crates/nova_ship/src/sections/skin_style.rs. - API detail:
cargo doc --open -p nova_ship(integrity and damage:-p nova_gameplay).
Scenario / modding system
How-to companions: Create your first scenario to write one in RON with existing primitives, or Extend the scenario engine to add new event, filter, action, or object kinds in Rust. The exhaustive construct-by-construct catalog for authors is the modding reference; this page is the engine’s internals.
crates/nova_scenario is the data-driven scenario engine, the layer for
missions, objectives, and reactive world behavior. A scenario is a list of
event handlers; each pairs an event with filters (all must pass) and actions
(run in order). It builds on GameEventsPlugin/EventWorld from
nova_events; nova_scenario provides NovaEventWorld and the enums below.
Three surfaces write that list: a RON file, a Rust builder under
nova_authoring, and the editor’s EVENTS mode, which lifts a handler into nodes
you select and inspect - a condition included, drawn as a page of its own - and
lowers them back on save (crates/nova_editor/src/event.rs). All three produce
the same ScenarioEventConfig, so nothing below cares which one wrote it. The
editor reads its TOOLTIPS off these configs too: a field’s doc comment is what
the panel says about it, through bevy/reflect_documentation.
Scenario structure
ScenarioConfig-id,name,description,cubemap(skybox),skybox_brightness(lux, defaults 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, hidden ones allowed); a first-class content kind (Campaign((..))).GameCampaigns(HashMap<CampaignId, CampaignConfig>)- all known campaigns, the ordered campaign->scenario mapping the Scenarios picker groups/launches by, populated 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. 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: final_tally’s flagship
and both of menu_duel’s corvettes appeared in placeholder art and dressed
themselves a moment later.
preload::scenario_render_meshes walks the loaded config for SpawnScenarioObject
and ScatterObjects actions, resolves each ship’s ShipSource and every
section’s SectionSource against the two catalogs, and collects the render
meshes. That walk is possible at all because a spawn action carries its object’s
WHOLE config inline rather than an id looked up at spawn time, so what a
scenario can spawn is readable before it spawns anything.
Three parts make it work:
AssetRef::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, bounded by its own deadline so a missing or broken mesh cannot hang the load. A failed mesh counts as settled and is named in a warning; the section spawns in placeholder art, exactly as it would have.
Ships are the only object kind involved. A beacon and a salvage crate build
primitives, an asteroid meshes itself on a worker, and a light and an anchor
have no mesh at all. The warm-up is also registered only when
NovaScenarioPlugin::render is set: a headless rig builds no mesh children, so
there is nothing to warm and nothing to wait for.
The vocabulary, and who documents it
Three closed enums are the whole authored language, one dispatch match each:
| 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 siblings - so
anything walking a config by reflection can offer the ids in scope and mark one
that resolves against nothing. The editor’s inspector is the reader that exists;
a surface keeping its own list of which field names what goes stale the day an
action is added, which is the failure the attribute removes. A new string field
that refers to something declares what, or it is a blank box.
Events carry identity, not payload-by-position: entities wear
EntityId(String) and EntityTypeName(String), and every PAIR event has the
same filter shape - a subject id plus an other_id / other_type_name.
Which entity is the subject is per-event (area against body, well against ship,
target against locker), which is why the filter is one struct rather than one
per event. Lock and orbit lifecycle events are one-shot EDGES with no hidden
timer behind them: a target switch queues end-old then start-new, and a
scenario that needs a continuous hold composes the edges with a keyed timer.
Filters read and never mutate; they take &NovaEventWorld and the fired
GameEventInfo and return a bool, and every filter on a handler must pass.
Actions take &mut NovaEventWorld and run in order. Neither touches the Bevy
World directly - see the seam below.
flowchart LR Event["Event fires"] --> Filters["Filters gate"] Filters -->|all pass| Actions["Actions run"] Filters -->|any fail| Stop["No-op"] Actions --> Vars["Mutate variables"] Actions --> World["Mutate event world"] Actions --> Objects["Spawn / affect objects"] Filters -->|"all pass + once"| Retire["Handler despawns"]
A once handler retires on the PASS edge, so a refused event leaves it live
and a beat waiting on a condition keeps every later chance at it. Retirement
is a despawn, which maintain_handler_index turns into an index removal
before the next dispatch; the dispatcher also holds a pass-local spent set,
because one drain pass walks the whole queue against a single index snapshot
and two queued events of the same name would otherwise reach the same handler
twice.
What an action does that its RON cannot show
Most actions are a straight write into NovaEventWorld. Four are not, and the
difference is engine behaviour rather than authored syntax:
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.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 -
flow, mission, sequence, ship, spawn, timer, view - and adding
one is Extend the scenario engine.
Sequence keeps its cursor in the engine
Sequence is the one action whose state does not live in the action. A
SequenceActionConfig is an authored LITERAL key plus an ordered list of
steps; running it calls NovaEventWorld::start_sequence, which files a
SequenceRun - key, steps, cursor, the time the step became current - in the
event world beside the keyed timers. The config is immutable and shared
(Arc<Vec<SequenceStepConfig>>), so the same chain can be authored once and
started from several handlers.
The cursor CANNOT live in the action: handlers are dispatched from an index
snapshot and an action config is read-only during a pass, so a step counter
held there would be per-fire, not per-run. Keying it by an authored literal
also keeps content lint whole-program - no id in authored content is
computed, so the linter still resolves every reference statically.
Three pieces move a chain forward:
advance_scenario_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, four walkers had to learn
to recurse into it: inline_queries, object_count, the lint’s
collect_declared / check_action, and the per-event spawn-id pass. The
shared helpers are EventActionConfig::walk and
ScenarioEventConfig::action_groups, which returns a handler’s own actions
plus one group per Sequence step it starts, however deeply nested. Any new
rule that reasons about “one frame” reads action_groups, not actions.
Variables and the event world (world.rs, variables.rs)
NovaEventWorld holds the scenario state: variables, objectives,
next_scenario, and a queue of deferred command closures. Filters and actions
mutate only this resource, never the Bevy World; world access goes through
world.push_command(|commands| ...). Each frame state_to_world_system syncs
objectives into GameObjectives (write-on-diff), runs a queued non-lingering
NextScenario switch, and drains the command queue.
The drain is CHUNKED, not one flush. A chapter’s OnStart queues a closure per
object, and applying them together cost one ~300 ms frame - a frame nothing can
be drawn on, so the LOADING panel froze on the exact frames it exists to cover.
Commands are applied one at a time until SPAWN_DRAIN_BUDGET (3 ms) of the
frame is spent, so a big scene arrives over several frames and a slower machine
takes MORE FRAMES rather than a longer one. One command per apply is also what
keeps each object atomic: a ship’s sections all land inside one apply, so the
Added<SectionLinkPoints> batch the integrity graph and the derived skin key
off is complete the first time they see it.
While commands remain, the scenario is SETTLING (EventWorld::is_settling).
The dispatcher holds every handler, and a handler that queues world work stops
the current pass, so no handler ever runs against a world known to be
incomplete. The scenario clock stops, keyed timers do not expire, watches are
not sampled, the OnUpdate pulse does not fire, and the LOADING panel stays up.
The world is not yet LIVE, rather than briefly inconsistent. Held events are not
dropped: they dispatch in order on the frame the world goes live.
scenario_has_settled - the run condition the clock and the pulse read - holds
for one more reason: the glTF warm-up above. Dispatch is not, so OnStart
still fires and the scene still builds while the art arrives; what waits is the
scenario CLOCK, so no mission time passes behind a panel the player cannot see
past.
Variables are typed literals (String, Number, Boolean) with a small
expression tree: VariableExpressionNode (add/subtract), VariableTermNode
(multiply/divide), VariableFactorNode (literal/name/parens);
VariableConditionNode (less/greater/equal) yields booleans for filters.
The tree has a TEXT form (syntax.rs): Display renders it as
scenario.elapsed > 90 and FromStr parses that back. The authored form is
still the RON nest - the text is what one editor row can hold, and what a tree
row reads as when it is shut. Round trip is the contract in both directions,
parse-of-render and render-of-parse, which is what lets a panel own a condition
without a save quietly rewriting it. The syntax spells the grammar and nothing
more: no operator the crate cannot evaluate, and a - b - c parses rightward
because Subtract(Term, Expression) nests that way.
The editor takes a condition APART along the same grammar: each operator is one
document node with its two sides as children, and a leaf holds whatever fits one
row of the text form. Those nodes are NOT part of the tree - the rail shows the
filter and stops - and the panel draws the whole condition as a page instead,
one row per node, each writing to its own entity. Parens is dropped on the way
in - the nesting says what the brackets said - and put back on the way out
wherever the position needs it, so a sum under a product lowers as (a + b) * 2.
What a switched operator cannot hold it does not keep: an operand a value has no
place for is dropped rather than left hanging where no row would show it.
Two clocks pace a transition
/create/actions/ documents the three gears a scenario switch has - hard cut,
delayed cut and modal hold. The engine fact underneath them is that they do not
all run on the same clock, which is the only part that is not obvious from the
RON:
- A
NextScenariodelay ticks onTime<Virtual>, the pause-frozen scenario clock, so a player who pauses holds the cut. - 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
StoryMessage writes into a bottom-left comms stack rather than a
latest-wins line: arrival order, a bounded number of cards visible with a
bounded backlog behind them, oldest dropped when the backlog overflows, and the
whole log kept in the feed. That is why a burst of lines is survivable - but
one line per beat is still the style, and the queue is the safety net.
Two consequences for anything that fires story lines:
- The stack is a HUD surface
nova_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.
Measured on a headless run, as the share of frames that queue the event:
| scenario | pulses / frames | why |
|---|---|---|
final_tally | 308 / 18300 | value-gated milestones |
broadside | 355 / 13500 | value-gated milestones |
shakedown_run | 425 / 16800 | value-gated milestones |
ledger_ch1 | 350 / 22500 | milestones plus four scheduled lines |
ledger_ch3 | every frame | a player_speed ladder, correctly polling |
lifeline | every frame | recomputes a HUD countdown per frame |
Scenario patterns
The engine holds three facts for content - once, keyed timers, and a
Sequence cursor - and everything past those is one numeric variable plus
Expression filters. Two variable idioms recur; both are worked end to end in
the Gauntlet worked example below. Excerpts
here are verbatim from webmods/gauntlet/gauntlet.content.ron.
once and Sequence are what a variable is NOT for. A flag whose only reader
is its own filter - seeded in OnStart, read by one gate, written by that
gate’s own action - is the engine’s fact, and once carries it. A step
counter whose only job is to keep paced beats in order is the engine’s fact
too, and a Sequence cursor carries it. Keep a variable where another handler
reads it: an ORDERING counter like gate below, driven by where the PLAYER
is, or a signal like “the convoy lost a ship”.
The gate-counter ordering pattern
A single numeric variable acts as a state machine that enforces ORDERED entry: each stage’s handler is guarded on the counter holding that stage’s value, and the last thing the handler does is bump the counter to arm the NEXT stage only. An event that arrives out of order finds the counter on a different value and does nothing.
Gauntlet’s variable is gate (the index of the gate to thread next, 1..=7).
OnStart seeds it:
VariableSet((
key: "gate",
expression: Term(Factor(Literal(Number(1.0)))),
)),
Each gate’s OnEnter handler carries two filters - an Entity filter that
matches the area/body, and an Expression filter that pins the counter - so
only the in-order entry fires:
(
name: OnEnter,
filters: [
Entity((
id: Some("gauntlet_gate_1"),
other_id: Some("player_spaceship"),
)),
Expression((Equal(
Term(Factor(Name("gate"))),
Term(Factor(Literal(Number(1.0)))),
))),
],
actions: [
ObjectiveComplete((id: "gate_1")),
VariableSet((
key: "gate",
expression: Term(Factor(Literal(Number(2.0)))),
)),
// ... re-point the objective marker at gate 2 ...
],
),
Because gate 2’s handler filters Equal(gate, 2.0), flying through gate 3
early - or back through gate 1 again - matches no live handler and is inert. The
scenario_gate_course rig’s
gates_advance_only_in_order_and_only_for_the_named_ship test pins exactly
this on a synthetic course: an out-of-order entry does not advance gate.
Use it whenever stages must be visited in sequence (a gate run, a guided tour, a
tutorial’s step chain). The base shakedown_run starter uses the same idiom
with a beat counter; see Built-in scenarios.
The act-gating pattern
A post-decision event can otherwise flip an already-decided outcome: in Gauntlet a wreck normally means Defeat, but a wreck that drifts into a rock AFTER the win must not overwrite the earned Victory. The fix is to guard the Defeat handler on the same counter, past a terminal value the winning handler sets.
The FINISH handler bumps gate one past the last real gate (to 8.0, the
terminal done-state) as it declares Victory:
// Terminal: bump past the last gate so no OnEnter re-fires
// AND the player-death Defeat handler (gated gate < 8) can
// never flip an earned Victory to Defeat.
VariableSet((
key: "gate",
expression: Term(Factor(Literal(Number(8.0)))),
)),
Outcome((
outcome: Victory,
message: Some("You ran the gauntlet clean. ..."),
)),
The OnDestroyed Defeat handler is then guarded gate < 8, so a death blast
after the course is finished declares nothing:
(
name: OnDestroyed,
filters: [
Entity((
id: Some("player_spaceship"),
)),
Expression((LessThan(
Term(Factor(Name("gate"))),
Term(Factor(Literal(Number(8.0)))),
))),
],
actions: [
Outcome((
outcome: Defeat,
message: Some("You wore your hull down to nothing ..."),
)),
NextScenario((
scenario_id: "gauntlet_run",
linger: true,
)),
],
),
The rig’s a_wreck_after_the_finish_declares_nothing test seeds gate to
8.0, fires the death, and asserts no outcome and no retry (its sibling
a_wreck_before_the_finish_declares_defeat_with_a_retry pins the other half).
Use this whenever a lethal
event can still fire after the scenario is decided (a boss’s death explosion
catching the player, a wreck sliding into a hazard): pick a terminal counter
value the winning handler sets, and guard every outcome handler against it.
The Gauntlet worked example
webmods/gauntlet is the reference implementation for both patterns. Trace it
end to end:
- The content file
webmods/gauntlet/gauntlet.content.ron- one NEW scenario, no base overrides; the gate-counter and act-gating idioms above live here with header comments explaining the two geometric invariants. - The time-trial wiring:
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. Geometry invariants (gate areas do not overlap; the racing line clears every rock’s worst-case body pastASTEROID_GEOMETRIC_FACTOR_MAX) are a CONTENT concern, checked per bundle bycontent lint. - The first-scenario guide’s completed flow is the gentler, single-counter cousin of the gate-counter pattern.
Scenario objects (objects/, ScenarioObjectKind)
One module per kind under objects/, one arm in the ScenarioObjectKind match
in actions/spawn.rs. The authored fields of each kind - and every trap in
them - are /create/objects/; what follows is what the modules have in common.
All share BaseScenarioObjectConfig (id, name, position, rotation) and spawn
scoped entities via base_scenario_object, which deliberately carries NO body:
each kind declares its own RigidBody, and the asteroid alone opts into
Dynamic + TransformInterpolation. A carved rock also emits new dynamic
bodies at runtime - every piece a crater severs (CarvedChunkMarker,
integrity/chunk.rs) - so the spawn kinds are not the whole population of a
live scene.
Three engine facts the object configs do not show:
- Nothing supplies a light.
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. - A rock has no
healthfield, and that is not an omission. What an asteroid is made of IS its durability; the mechanism is below. Itsmassis the body’smuand sets both the pulla = mu / r^2and the sphere of influence - the distance where that decays toGravitySettings::soi_cutoff_accel- so a well is authored by the SOI it should have,mu = soi_cutoff_accel * soi^2. AnAnchorpublishes the sameGravityWellfrom an AUTHORED radius instead of a mesh-derived one, which is what makes it deterministic where a carved rock is not. - Ship section geometry is LINTED, not clamped. Overlapping unit-cube cells
and a turret or torpedo mount whose base (local -Y under its rotation) faces
an empty neighbour cell are
content 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. FIELD_CELL_WORLD is 0.5 WORLD units, and the cell COUNT is
derived from it - the opposite way round from how this started. A crater is a
world-sized thing (a 4-damage PDC round carves 0.62 units whatever it lands on),
so a grid whose cells grew with the rock could not draw that round’s hole on
anything big: 32 cells across a radius-3 rock is a 1.02 unit cell, four times the
round being fired at it. Coarseness is the ART, not a resolution knob - a finer
grid only makes a smoother rock. FIELD_RESOLUTION_MIN is 16 and
FIELD_RESOLUTION_MAX is 40; the cap BINDS above about radius 1.8, and what it
costs there is the cell (a radius-3 rock is gridded at 0.82 units, so a PDC round
on one is under a cell and only sustained fire - whose mark GROWS where it is
held - opens a hole). 41^3 corners is 275 KB per carved rock, paid only by
rocks that are hit. FIELD_MARGIN is 1.08, only just over 1 because carving
never ADDS material.
Nothing happens in the frame that asked for it. The seed and the whole carve
- split, surface nets, collider, and the geometry of every piece the cut freed -
run on the async compute pool, at most one job in flight per rock, and the rock
keeps drawing the surface it already had until one lands. What the main thread
pays is the sphere subtraction the mark itself reaches, and then PLACEMENT: one
transform and one spawn per piece. A remesh also waits until the grid actually
loses a cell (a quantized
meshed_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, and nova_protocol/ owns the campaign chapters plus
shared cast and pacing vocabulary. Its shakedown/ module builds the New Game starter - the
beat-chain reference: one beat counter gates every handler, and count
milestones run on OnUpdate handlers keyed on the counter (handler order
within one event is not load-bearing). The builders are an OFFLINE inventory,
not the runtime path: content -- gen serializes them to
the committed assets/base/scenarios/*.content.ron, base.bundle.ron lists
them, and crates/nova_assets/src/merge.rs merges the parsed RON into
GameScenarios like any mod’s. content_ron_parity pins builders == RON.
Adding new pieces
- Event: event + info structs in
nova_events/src/lib.rs, anEventConfigvariant inevents.rs, and something that fires it (engine-driven events live inloader/-OnStartinlifecycle.rs,OnUpdateinclock.rs, the orbit/lock 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 (flow/mission/sequence/ship/spawn/timer/view), plus anEventActionConfigvariant inactions/mod.rs. - 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; what the pulse wakes for:WakeProfile,configure_scenario_shape-crates/nova_scenario/src/loader/wake.rs. - Objects:
ScenarioObjectsPlugin-crates/nova_scenario/src/objects/mod.rs; kind dispatch:ScenarioObjectKind-crates/nova_scenario/src/actions/spawn.rs. - Asteroid carving:
AsteroidField,carve_asteroid_fields,pristine_field-crates/nova_scenario/src/objects/asteroid_carve.rs; the mesher underneath:SignedField-crates/nova_gameplay/src/mesh/field.rs; the cost of the material itself:DAMAGE_PER_UNIT_VOLUME-crates/nova_gameplay/src/integrity/carve.rs. - API detail:
cargo doc --open -p nova_scenario(event engine:-p nova_events).
The automation harness
How Nova Protocol drives itself without a human at the keyboard: a scripted autopilot walks the game through its states, screenshot drivers capture the result, and a completion protocol decides when the run is over. It is what makes headless verification, the web figures and the performance reports repeatable rather than a manual pass.
All of it lives in the nova_autopilot crate, which depends on bevy alone -
no nova_* crate, no avian3d. It ships the drivers and the protocol; it does
not ship anything Nova-specific. The adapters that know about Nova (scenario
presets, camera posing, freezing rigid bodies, hiding the dev overlay) stay in
nova_debug and reach in through caller-supplied closures. The drivers are
generic over the app’s state type, and that generic is what keeps
GameStates - and with it the whole game dependency tree - out of the crate.
Read this page as the crate’s contract. Nova’s own examples run these
drivers, reaching them through the nova_debug prelude and the
nova_debug::harness presets - the Nova-flavored adapter, not a second
implementation - while nova_probe names nova_autopilot::completion
directly.
What it drives
| 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 window-sized NOVA_AUTOPILOT_DEADLINE for its fps pass, which your
own value overrides. Its in-game half, nova_probe, is what the subject
wires to collect the evidence.
A harness run is headless, which for these drivers means a SOFTWARE X server, and that is not free: presenting a window under Xvfb costs a CPU copy of every pixel, charged to the frame. Correctness is unaffected and so is any ratio, but an absolute millisecond off a headless run is the game plus the display server. Measuring performance has the size of it and what survives.
The subject is usually an example, which wires the collectors itself. For
probe scenario it is the GAME BINARY: src/main.rs adds
nova_probe::NovaProbePlugin and the nova_autopilot() preset under the same
debug feature that carries the probe subcommand. Both are inert without
their variables, so a plain cargo run --features debug behaves exactly as
before - and a scenario becomes measurable without an example file existing for
it.
The environment contract
Every driver is inert unless its own variable is set, so an app adds the plugins unconditionally and a normal run pays nothing for them. Setting the variable is what arms the driver; the value only matters where the table says so.
| 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 and its weapon locks - each ship’s sections with their
class, pose, health, modifications and magazine state, each section’s fixtures
(the skin plates and decor bolted to it), and every torpedo and round in flight
with its owner, damage, remaining lifetime and - for a torpedo - which ordnance
TYPE it is, since two bays on one hull can load different torpedoes that are
identical in every other field.
Use it when a defect would otherwise be judged from a render. A skin bug, a
section that took damage it should not have, a turret that never reloaded: all
of them are one jq query away instead of a picture to squint at.
Xvfb :95 -screen 0 1280x720x24 &
NOVA_AUTOPILOT=1 NOVA_PROBE_SNAPSHOT=/tmp/snap.jsonl \
NOVA_PROBE_SNAPSHOT_FRAMES=600,600 BEVY_ASSET_ROOT="$PWD" DISPLAY=:95 \
cargo run --example system_turret_gunnery --features debug
jq -S '.ships[0].sections[] | {id, class, health}' /tmp/snap.jsonl
Two rules make it a DIFFABLE artifact rather than a dump. Every list is sorted
by a value-derived key, never by entity id or query order, so a respawn that
renumbers entities does not churn the diff. Every float is rounded to four
decimals with -0.0 normalized, so the last bit of an f32 does not either.
Two snapshots of one frozen frame are byte-identical, which is what the repeated
frame number above checks.
It is read-only, deliberately. There is no restore: a scenario is already replayable and is its own checkpoint.
The completion protocol
One run can carry several collectors - an autopilot timeline, a frame capture -
each finishing on its own clock. A collector that writes AppExit on its own
clock discards every other collector’s data, and does it silently: a capture
cut short by another collector’s exit still writes a plausible file, just a
shorter one. So the exit is NEGOTIATED. Two rules:
- 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 |
until | the predicate that advances the step |
deadline | in-step seconds after which an unsatisfied until ABORTS the run, naming the step |
The step advances the first frame until holds. Name a step after what it is
waiting FOR, not after what it pokes - the name is what a stall message
carries, and “stalled on lock the prey” is a diagnosis where “stalled after 30
seconds” is a shrug.
Elapsed time is one predicate among many, so hold(state, secs) - enter a
state, wait N seconds - is sugar for
step("hold:<state>").enter(state).until(elapsed(secs)) rather than a second
mechanism. The vocabulary is in nova_autopilot::predicate (elapsed,
frames, state_is, resource_where, any_entity, ui_node_present,
pointer_pressed, pointer_released, pointer_at, shot_written,
loop_written, and, or, not), the
gestures in nova_autopilot::input (press_key, release_key, type_text,
press_edit_key, press_mouse, release_mouse, move_cursor, click_at,
scroll_lines, scroll_pixels), and the Nova-typed
predicates in nova_debug::harness (scenario_variable_is, section_gone,
player_ship_present, and the editor_* set below). Anything the vocabulary
cannot express is a plain closure: Arc::new(|world: &World| ...).
A key press is two channels, and a beat has to pick
Bevy delivers a key twice. press_key writes ButtonInput<KeyCode>, which is
what a VERB reads. press_edit_key writes a KeyboardInput message, which is
what a TEXT FIELD reads. Nothing bridges them, so a beat that drives one channel
proves nothing about the other: pressing only KeyCode::Delete at a focused
field asserts that the verb stayed quiet while the field was never asked to
delete anything, and pressing only Key::Delete asserts the opposite half.
A beat about who owns the keyboard therefore drives BOTH and reads both
outcomes - see examples/systems/system_input_modes.rs, which presses each key
down both channels and then asserts what the field holds as well as what the
document holds.
A UI gesture is three waits, not three sleeps
A click on a widget owes the app three acks, and each one is a predicate:
| 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 |
| 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.
What the button DID is the caller’s next beat, and it is a condition too. The
editor publishes its own decisions as data for exactly this: nova_editor’s
public, read-only EditorProbe carries the armed tool, what a click would build
right now (solved, refused with the reason, or nothing), whether the parts
gallery is up, whether its filter holds the caret, and which prototype the
selection resolves to. nova_debug::harness wraps those as editor_tool_is,
editor_part_armed, editor_placement_solved, editor_placement_refused,
editor_placement_clear, editor_gallery_open, editor_gallery_closed,
editor_filter_focused and editor_gallery_selected.
So a placing gesture reads: aim, hold until the editor SOLVED a placement there;
press, hold until the pointer registered it; release; hold until the section
landed. system_ship_editor and the screenshot_editor walk are written that
way end to end, and neither counts a frame outside a pre-shot settle.
Where the only observable IS the outcome, the beat may share the assert’s
quantity - the exception to the strictly-weaker rule below. “The socket
filled” has no stimulus-side twin: the count is both what the beat waits for and
what the verdict states. The trade is deliberate, and it is the better half: an
unmet until aborts naming the beat that missed, where a snapshot assertion a
fixed number of frames later reported a wrong number and left which gesture
produced it to be guessed. Keep the assert anyway - it is where the claim is
written down.
Typing is its own gesture because a key has two halves. press_key writes the
HELD state (ButtonInput<KeyCode>), which is what flight code polls;
type_text writes the keyboard MESSAGE carrying the text a keypress produced,
which is what a text field reads. A run that drives a filter or a name field
wants the second - pressing KeyR fills nothing in.
A driven run owns the pointer. The examples run on a real display, so a
real cursor event - the window manager’s enter/motion pair, a developer nudging
the mouse, the echo of the OS-level warp the driver itself performs - lands in
the same stream the synthesized one does. One of those between a press beat and
its release beat CANCELS the click silently: bevy_picking dispatches
Pointer<Click> from the PREVIOUS frame’s hover map, so a pointer that moved
off the widget in between produces a release, no click, and no Activate - a
90-second stall on the beat AFTER the one that actually went wrong. So the
autopilot pins its pointer: whenever the window disagrees with the last
position a gesture set, nova_autopilot::input puts the pinned position back
in First, before the picking backend reads the frame’s events. Nothing at a
call site changes, and a script needs no defensive re-hover. The pin holds the
REAL cursor too, since that is what Window::cursor_position moves - so a
driven run on your own desktop pulls the mouse back whenever you move it off,
until the run ends.
A settle predicate over a physics quantity belongs on the physics schedule.
“The value held still for N frames” is a common way to write “the solve is
done”, and on Update it does not mean that: avian runs in FixedPostUpdate,
so above the fixed rate most Update frames carry no solve pass at all and N
unchanged frames can all precede the recompute the beat is waiting for. Sample
in FixedPostUpdate after PhysicsSystems::Prepare, so one sample is one
pass, and carry any second fact the beat needs (an entity count, say) in the
same sample rather than reading it live off the world. system_hull_damage’s
ComSettle is the worked example.
The rule is “sample where the quantity is WRITTEN”, not “sample on the fixed
schedule” - system_turret_gunnery’s AimSettle stays on Update precisely
because its aim error is produced by SmoothLookRotationPlugin in
PostUpdate, so consecutive fixed ticks inside one frame would read the same
value and saturate
the streak mid-slew. What that costs is framerate independence, which you buy
back separately: a per-frame delta threshold means a different physical
threshold at every framerate, so compare a RATE against Time::delta_secs().
AimSettle is the worked example for that half.
Related, and the other half of the same mistake: a beat must be strictly
weaker than the assert that follows it. If the beat’s predicate and the next
step’s assert share a constant, the assert cannot fail and a real regression
surfaces as a deadline stall on the beat’s name instead of the message that
explains it. Gate on the stimulus and the world having stopped changing; assert
on where it ended up. When the two must read the same quantity, give the beat a
margin - the driver enters the next step on the FOLLOWING frame, so a beat that
opened at exactly the assert’s threshold on a falling value hands the assert a
failing one (system_attitude_hold’s OFFSET_BEAT_MARGIN_SECS, and
system_turret_gunnery’s GATE_TRAVEL_BEAT_MARGIN over a sweep that is not
even monotonic).
Where the invariant has no separate stimulus-side observable - “the torpedo
detonated”, “the burn accelerated” - the beat is the stimulus plus a bounded
settle sized off the mechanism (LAUNCH_SETTLE_SECS, BURN_WINDOW_SECS,
HIT_SETTLE_SECS), never the outcome itself. A settle is a runway, so it owes
a derivation on its constant; what it must not do is read the quantity the
assert decides.
The runway anti-pattern
The shape to avoid is a wall-clock RUNWAY plus one closure that re-derives a step machine from booleans:
#![allow(unused)]
fn main() {
app.add_plugins(
AutopilotPlugin::<GameStates>::new()
.self_completing()
.hold(GameStates::Loading, 30.0) // a runway, unrelated to Loading
.input(script), // every frame, in every state
);
app.add_systems(Last, guard_script_completion);
fn script(world: &mut World, elapsed: f32) {
if *world.resource::<State<GameStates>>().get() != GameStates::Playing {
return;
}
// Re-derive a step-relative clock by hand, because `elapsed` is the run's.
let playing_since = { /* get_or_insert into a script resource */ };
let t = elapsed - playing_since;
if t > 1.0 && !script.spun { script.spun = true; apply_spin(world); }
if t > 2.0 && !script.killed_controller { /* ... */ }
// ... and a hand-rolled panic if the runway expires with beats unplayed.
}
}
Everything in it is a symptom: the playing_since offset exists because the
closure’s clock is the RUN’s and not the beat’s, the booleans exist because a
closure has no notion of having advanced, and the hand-rolled guard exists
because nothing else knows the runway expired with beats unplayed. Each is
carried by the step list for free.
Write the beats themselves, each waiting on the world
(examples/systems/system_hull_damage.rs):
#![allow(unused)]
fn main() {
app.add_plugins(
AutopilotPlugin::<GameStates>::new()
.step("load the rig")
.enter(GameStates::Loading)
.until(player_ship_present()) // not "30 seconds should do it"
.deadline(15.0)
.add()
.step("spin the ship")
.on_enter(apply_spin)
.add()
.step("kill the controller section")
.on_enter(kill_frontmost_section)
.until(section_gone("controller")) // the real despawn, not a guess
.deadline(6.0)
.add()
// Last beat: the driver reports done after it, so the run ends on the
// assertion instead of idling out the rest of a runway.
.step("assert the com follows the surviving sections")
.on_enter(assert_com_follows_sections)
.add(),
);
}
There is no runway, and a stalled step is the driver’s job to report, by name.
Capturing: one idiom
There is one way to take a screenshot from a script, and it is a step. A shot
step calls shoot(world, "name.png") from on_enter, so the act, the framing
and the shot it produces read top-to-bottom in the step list:
#![allow(unused)]
fn main() {
.step("frame the planetoid")
.on_enter(|world| pose_camera(world, EYE, LOOK))
.until(frames(SETTLE_FRAMES))
.add()
.step("shoot wiki-gravity.png")
.on_enter(|world| shoot(world, "wiki-gravity.png"))
.until(shot_written("wiki-gravity.png"))
.deadline(SHOT_DEADLINE_SECS)
.add()
}
shoot is its own gate: it captures only when NOVA_CAPTURE is set, so the
SAME script is both the capture run and the smoke run.
shoot is asynchronous (the PNG lands at the end of a later frame), which is
why the shot step holds instead of ending immediately: move the camera in the
same frame and the pending capture renders the NEXT framing. It holds on the
ACK, not on a guessed number of frames - capture_window records the path in
CaptureLog once the write completes, and shot_written reads that. On the
smoke path, which shoots nothing, the same predicate holds immediately, so a
script never branches its step timing on capturing().
That leaves ONE settle in a capture script, the same on both paths:
SETTLE_FRAMES, the frames a beat needs to come to rest before its shot. The
per-example splits that preceded it were each carrying the write latency on top
of the stillness, which is why they disagreed.
The scene dressing is separate from the steps and lives in nova_debug::harness
too: force_capture_resolution (a known framing for every shot in the fleet),
hide_dev_overlays / hide_hud, and freeze_bodies for a posed set that must
not drift between framings.
A moving figure is the same idiom on a longer beat: loop_start / loop_end
around the action, loop_written to hold the closing step, and
LoopCapturePlugin next to the script. Every size and rate that recorder uses
is ONE value, LoopProfile - the window it renders into, the resolution it
encodes to, the frame clock, the CRF and the frame cap:
#![allow(unused)]
fn main() {
app.add_plugins(LoopCapturePlugin::default()); // 1920x1080 -> 720p30
app.add_plugins(LoopCapturePlugin::new(LoopProfile { // a portrait master
window_resolution: (1080, 1920),
output_resolution: (1080, 1920),
fps: 60,
crf: 18,
frame_cap: 1200,
}));
}
LoopCapturePlugin::default() is what the docs fleet captures with; a
production caller passes its own profile and changes nothing else.
force_capture_resolution reads the profile the plugin publishes (armed or
not), so the window, the frame clock and the encode cannot disagree about the
framing - and a profile whose output equals its window encodes with no scale
filter at all, rather than resampling a master through itself.
Do not add a second capture idiom beside it. A driver that walks its own list
of shots builds that list away from the script producing the state each shot
frames, which puts timing and framing in different files - and they drift. A
range that only wants ONE settled picture of itself does not get a driver
either: nova_screenshot(script) appends the settle-and-shoot beat to the
script it already has.
Deadlines
A step’s deadline is IN-STEP seconds and is unset by default, leaving
NOVA_AUTOPILOT_DEADLINE as the run-level backstop for a script that hangs
somewhere without one. Set a deadline where a stall is worth NAMING, and keep
the sum of a script’s deadlines under the run-level value - otherwise the
generic hang detector wins the race and the named-step diagnostic is lost. That
ordering is documented, not enforced: the run-level value comes from the harness
that launches the process, which the crate cannot see.
Do not enter a state something else owns
enter force-sets NextState, which is why a Nova script does not enter
GameStates::Playing: the Loading -> Playing transition is asset-gated by the
loader, so forcing Playing either fires before the GameAssets resource
exists (panicking the scene setup that reads it) or re-enters a state the loader
already entered, double-running OnEnter(Playing). Enter the state BEFORE the
gate and let the loader do its own transition, then wait on something the load
produced - player_ship_present(), a seeded scenario variable. Nova’s
nova_autopilot() preset (crates/nova_debug/src/harness.rs) is the wall-clock
fallback for examples with nothing observable to wait on.
Then arm it from the shell - driven_app is the crate’s own example, the
scenario forms are Nova’s:
NOVA_AUTOPILOT=1 cargo run -p nova_autopilot --example driven_app
NOVA_AUTOPILOT=1 NOVA_CAPTURE=1 NOVA_CAPTURE_DIR=target/shots \
cargo run --example system_scenario_grammar --features debug
NOVA_AUTOPILOT=1 NOVA_CAPTURE=1 NOVA_CAPTURE_DIR=target/shots \
cargo run --example screenshot_gravity --features debug
crates/nova_autopilot/examples/driven_app.rs is the end-to-end read: a
self-contained DefaultPlugins app with its own state machine, driven through
named predicate steps and exited by the completion protocol, importing no
nova_* crate but nova_autopilot. Run it with
NOVA_AUTOPILOT=1 cargo run -p nova_autopilot --example driven_app;
crates/nova_autopilot/tests/autopilot_example.rs runs the same thing headless
and asserts on the exit status and the log lines.
The full API reference is the crate’s rustdoc
(cargo doc -p nova_autopilot --open); every public item is reachable through
nova_autopilot::prelude.
Find it in the code
- Driver:
AutopilotPlugin-crates/nova_autopilot/src/autopilot.rs;capture_window-crates/nova_autopilot/src/capture.rs. - Protocol and vocabulary: completion -
crates/nova_autopilot/src/completion.rs; predicates -crates/nova_autopilot/src/predicate.rs; gestures -crates/nova_autopilot/src/input.rs. - Nova adapter: the
nova_autopilot()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.
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 | 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 | 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.
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 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.
The menu
| Variable | Gates | Owner | For |
|---|---|---|---|
NOVA_MENU_BACKDROP | pins the menu backdrop to one menu_backdrop scenario id instead of re-rolling the draw; an unknown id warns and falls back | nova_menu | harness |
Not on the roster
- Example-local knobs.
NOVA_STRESS_PD_*,NOVA_EDITOR_FRAMELOG,NOVA_SOAK_SCENARIO,NOVA_SOAK_SECSbelong to one example each and stay literals beside the range that reads them. NOVA_OS_*. Around 180 of these exist and NONE is an environment variable: they areconst Color, layout and volume values innova_os_ui,nova_osandnova_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;NOVA_BENCH_*bybenchmark/. No Rust reads any of them. - Foreign variables the code legitimately reads:
DISPLAY,WAYLAND_DISPLAY,RUST_LOG,BEVY_ASSET_ROOT,CARGO_*,CI,XDG_DATA_HOME,XDG_CONFIG_HOME,LVP_ICD,RUSTFLAGS,VK_ICD_FILENAMES,VK_DRIVER_FILES,WGPU_BACKEND,TRACE_CHROME.
Adding one
- 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 window, and the deadline sized to it
The capture window is the capture crate’s full 180/900 baseline unless the
example declared one of its own, so probe numbers stay comparable with the
sweep’s; your NOVA_PROBE_WARMUP / NOVA_PROBE_FRAMES override both. The
completion deadline is SIZED to the BASELINE window (not a flat 120s, and a
ceiling for any shorter one an example declares): probe sets
NOVA_AUTOPILOT_DEADLINE for the fps pass to (warmup + frames) / ~2fps + margin, so a slow-but-progressing capture (a heavy scene in a dev build under
software rendering - the stress_* ranges are the case) completes instead of
tripping the hang detector; a genuine hang still fails at a window-appropriate
bound, and your own NOVA_AUTOPILOT_DEADLINE overrides it. Every example’s main
returns AppExit, so a deadline expiry is a non-zero process exit the
process_exit check reports. See the crate docs for the full knob list
(NOVA_PROBE_*).
Sweeping presets, renderers and the web
The perf sweep is the same front door: a preset matrix of the frame-time
capture, one labeled frametime.csv row per cell, release-built (dev-profile
frame numbers are not baselines):
cargo run --features debug probe run stress_bullets --release --preset high --preset low
cargo run --features debug probe run stress_bullets --release --render sw ... # lavapipe floor
cargo run --features debug probe run stress_bullets --release --norender # no renderer at all
cargo run --features debug probe run <scenario> --platform web # web/WebGPU capture (scraped)
--render picks the BACKEND for every pass, the frame-time one included: sw
forces the lavapipe ICD and its short 20/120 window. --norender decides
whether anything draws at all, sets NOVA_NORENDER in the child, starts no
Xvfb, and is refused alongside --render - there is no backend to pick when
nothing draws. It keeps the 180/900 baseline window, because a headless run has
no fill cost to shorten around, and it is native only: a wasm run has no process
environment to set. Its rows name themselves in frametime.csv - backend and
adapter both read unknown, there being no adapter. It measures the main
schedule alone, so it CANNOT see a render-side panic; a speed option beside a
rendered run, never instead of one.
To measure a named SHIPPED scenario, use the probe scenario verb rather than
run --scenario: it launches the game binary itself and needs no example.
run --scenario only sets NOVA_PROBE_SCENARIO, which no cataloged example
reads on the native side; on --platform web it is load-bearing, because
nova_perf_web takes the scenario id from the URL.
Every capture records run metadata (wgpu backend + GPU adapter, resolution,
graphics preset, git SHA, host and - schema v3 - the BUILD PROFILE) so a
results file names its own renderer (pre-v3 files, like the v0.7.0
baseline, still load; their profile reads unknown). The report badges
each row dev or release: dev numbers are NOT baselines, and because the
capture is wired by default, the badge is what keeps ad-hoc dev captures from
being mistaken for comparable measurements. The web platform
builds the perf_web wasm app through Trunk, serves it from an embedded static
server, drives headless Chromium with the calibrated WebGPU flags, and
scrapes the summary line into a labeled CSV row (no fs in the browser).
Compare runs with probe report <after> --baseline <before> - signed deltas
per label - and report only accepts dirs probe itself produced
(probe-run.json is the gate).
Profiled pass (where does the time go)
Per-system costs come from a SEPARATE traced run - tracing overhead inflates frame times, so a profiled run RANKS systems while the clean capture owns the FPS truth (never mix the two):
cargo run --features debug probe run system_scenario_grammar # trace + report table
cargo run --features debug probe run system_scenario_grammar --samply # + flamegraph
The profiled pass builds with --features debug,trace (bevy’s per-system
spans are compiled in only under bevy/trace), runs headless with
TRACE_CHROME into the run dir (plus the RUST_LOG=bevy_ecs=info override
that un-hides the spans from the game’s log filter), and the report renders
the top-N table (probe report <run-dir> re-renders it). Open the raw
trace.json in https://ui.perfetto.dev for the full picture; samply load
opens the flamegraph in the Firefox Profiler (the samply run is skipped with
a note when samply is missing or blocked - sampling needs
perf_event_paranoid <= 1 AND, on many-core hosts, enough perf ring-buffer
memory: an “mmap failed” means raising perf_event_mlock_kb, e.g.
echo 16384 | sudo tee /proc/sys/kernel/perf_event_mlock_kb). The samply
run builds with the dedicated profiling cargo profile (full DWARF in the
binary + frame pointers via RUSTFLAGS) so our frames symbolicate to real
names instead of raw addresses; frames inside the NVIDIA driver blob and
stripped system libraries stay hex - that is their stripping, not a build
problem. Load the profile right after recording: symbolication resolves
from the binary on disk, so a rebuild in between loses names.
Expect the trace to be enormous. It carries one span per system per frame and grows at roughly 28 MB per second of traced gameplay, with nothing capping it, so a long range leaves GIGABYTES in its run dir - the report prints the size beside the table. That is deliberate: the raw file is the Perfetto artifact, and a byte cap or a span filter would buy disk by truncating the deep dive it exists for. The host reads it as a STREAM instead, at flat memory (about 70 MB peak, whatever the file’s size), so the cost is disk and disk only. It is a scratch artifact: keep it while you are profiling, delete the run dir when you are not.
Find it in the code
- The capture, its window and its knobs:
FrameTimePlugin,nova_frametime-crates/nova_probe/src/capabilities/frametime.rs. The crate rustdoc (cargo doc --open -p nova_probe) carries the 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_os_ui, and nova_authoring,
ending at a runnable example.
Why it is closed
Section kinds are first-class engine concepts, not content. Each kind carries
its own typed config, its own spawn bundle, its own behavior systems, and its
own arm in every exhaustive match over section kinds – none of which a RON
author could supply. What IS data-extensible is the section CATALOG: a SectionConfig
(base stats + one SectionKind instance) is authored in RON and loaded at
runtime (see the Ship sections reference and
modding). New KINDS are code; new INSTANCES of an existing
kind are data. This guide is about the former.
The closed enum is a feature: it means “did I wire the new kind into the ship computer / the editor / spawning?” is a compile error, not a silent gap.
flowchart LR
A[config module] --> B[SectionKind enum]
B --> C[SectionClass label]
C --> D[section plugin]
D --> E[spawn arm]
E --> F[editor place + gallery]
F --> G[asset prototype]
G --> H[example]
Checklist
Do these in order. Steps 2-7 will not compile until the ones before them exist, and the exhaustive matches force you through 3-7 once the enum has the variant.
Replace <kind> / <Kind> below with your section name (e.g. shield /
Shield).
-
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 ) } }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 NOVA OS ship app labels sections by this enum: add an arm to the exhaustive matches
code_prefix,kind_glyph,kind_descriptionandkind_indexincrates/nova_os_ui/src/ship/sections.rs, and tosection_kind_labelincrates/nova_os_ui/src/terminal/content.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 / infinite-ammo handling only if your kind needs it (see theTurret/Thrusterarms for those patterns):#![allow(unused)] fn main() { SectionKind::Shield(shield_config) => { section_entity.insert(shield_section(shield_config.clone())); } }This is the production spawn path; see the Scenario engine for how the spaceship object and its section observer fit together.
-
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. In
crates/nova_authoring/src/base_content/sections/standard.rs, add aSectionConfigtostandard_section_prototypes()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; NOVA OS label matches -crates/nova_os_ui/src/ship/sections.rs. - Spawn arm:
insert_spaceship_sections-crates/nova_scenario/src/objects/spaceship.rs. - Editor arms:
default_binds_for-crates/nova_editor/src/placement.rs;insert_preview_section-crates/nova_editor/src/preview.rs;GalleryCategory-crates/nova_editor/src/gallery/catalog.rs. - Cross-crate prototype ids:
crates/nova_ship/src/sections/catalog_ids.rs. - Prototypes:
standard_section_prototypes-crates/nova_authoring/src/base_content/sections/standard.rs. - API detail:
cargo doc --open -p nova_ship.
Extend the scenario engine
The scenario engine is config-open but code-closed. Authoring a scenario in RON
from the primitives that already exist is a data change and lives in its own
guide (see Create your first scenario). Adding a NEW
primitive - a new event, filter, action, or object kind - is a Rust change, and
each of the four follows one repeated recipe: define the config, wire it into
the matching dispatch enum, add the arm on the trait impl that fans out to it,
and export it. This is the “how to add” companion to
Scenario engine (the “what it is” reference); read that
first for the vocabulary (NovaEventWorld, handlers, filters, actions, scoped
objects).
The dispatch shape is identical everywhere: an enum variant carries a config
struct, a match arm on the trait impl delegates to that config’s own impl.
flowchart LR Variant["Config enum variant"] --> Arm["match arm on the trait impl"] Arm --> Impl["config's own trait impl"] Impl --> Effect["fires / filters / mutates"]
The NovaEventWorld seam
Read this once; recipes 2 and 3 depend on it. Filters and actions never touch
the Bevy World directly. They see only NovaEventWorld
(crates/nova_scenario/src/world.rs), the resource that holds scenario state:
variables, objectives, next_scenario, and a queue of deferred command
closures. What a filter or action may touch:
world.get_variable(key)/world.insert_variable(key, VariableLiteral)- the typed scenario variables.world.push_objective(ObjectiveActionConfig)/world.remove_objective(id)- HUD objectives (synced write-on-diff intoGameObjectives).world.next_scenario = Some(NextScenarioActionConfig { .. })- queue a scenario switch.world.push_command(|commands| ...)- defer anything that needs real world access (spawning, querying entities, resource mutation). The closure gets a&mut Commands; for a full&mut World(id -> Entity lookups) queue acommands.queue(move |world: &mut World| ...)inside it, the shapeDespawnScenarioObjectActionConfiguses.
Nothing runs against the world synchronously. Each frame
NovaEventWorld::state_to_world_system (in world.rs) syncs objectives into
GameObjectives, runs a queued non-lingering NextScenario switch, then drains
the command queue - so every push_command closure lands at frame end, in
order. The unit tests in the actions/ submodules exercise exactly this:
mutate a NovaEventWorld, call
NovaEventWorld::state_to_world_system(&mut world) to
drain, then assert on the world (see
despawn_action_removes_the_scoped_object_by_id in actions/spawn.rs).
Recipe 1: add an event kind
An event is fired somewhere in the engine, and scenarios react to it through a
handler. Adding one is two files: the event type (nova_events) and its config
variant (nova_scenario), plus the firing site.
-
In
crates/nova_events/src/lib.rsdefine the marker event and its info struct with theEventKindderive. The info is what a handler’s filters read; give it the pair shape (id,other_id,other_type_name) if it targets an object so theEntityfilter composes like the others.#![allow(unused)] fn main() { #[derive(Debug, Clone, EventKind, Reflect)] #[event_name("ondocked")] #[event_info(OnDockedEventInfo)] pub struct OnDockedEvent; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, Reflect)] pub struct OnDockedEventInfo { #[serde(rename = "id")] pub id: String, #[serde(rename = "other_id")] pub other_id: String, #[serde(rename = "other_type_name")] pub other_type_name: String, } }Export both from the
nova_eventsprelude (thepub use super::{...}block at the top oflib.rs). -
In
crates/nova_scenario/src/events.rsadd the variant toEventConfig(grep forenum EventConfig) and the arm toimpl From<EventConfig> for EventHandler<NovaEventWorld>(grep forimpl From<EventConfig>):#![allow(unused)] fn main() { pub enum EventConfig { // ... OnDocked, } // in the From match: EventConfig::OnDocked => EventHandler::new::<OnDockedEvent>(), } -
Fire it. Engine-driven events fire from
crates/nova_scenario/src/loader/withcommands.fire::<OnDockedEvent>(OnDockedEventInfo { .. })(see theOnStartsite inloader/lifecycle.rs,OnUpdateinloader/clock.rs, and orbit-lifecycle/the lock events inloader/trackers.rs); object-local events (an area entering/leaving) fire from the object’s own observer, the wayobjects/area.rsfiresOnEnterEventfrom its own trigger. A kind may also fire from a system it owns:objects/asteroid_carve.rsfiresOnDestroyedEventout ofcarve_asteroid_fieldswhen a rock’s field is exhausted, because “destroyed” there is a geometry test rather than a health-zero marker.
EventConfig is Copy and derives serde, so the new variant is authorable from
RON with no extra work.
Recipe 2: add an event filter
A filter gates whether a handler’s actions run; all filters on a handler must
pass. Everything lives in crates/nova_scenario/src/filters.rs.
-
Define the config struct and its
EventFilter<NovaEventWorld>impl.filterreturns a bool and may readworld(variables) andinfo(the fired event data); it must not mutate.#![allow(unused)] fn main() { #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct VariablePresentFilterConfig { pub key: String, } impl EventFilter<NovaEventWorld> for VariablePresentFilterConfig { fn filter(&self, world: &NovaEventWorld, _: &GameEventInfo) -> bool { world.get_variable(&self.key).is_some() } } } -
Add the variant to
EventFilterConfig(grep forenum EventFilterConfig) and the arm toimpl EventFilter<NovaEventWorld> for EventFilterConfig(grep forimpl EventFilter<NovaEventWorld> for EventFilterConfig):#![allow(unused)] fn main() { pub enum EventFilterConfig { Entity(EntityFilterConfig), Conditional(ConditionalFilterConfig), Expression(ExpressionFilterConfig), VariablePresent(VariablePresentFilterConfig), } // in the filter match: EventFilterConfig::VariablePresent(config) => config.filter(world, info), } -
Export the config struct from the module
prelude(thepub use super::{...}block at the top offilters.rs). -
Make it authorable in the editor (see Two surfaces author the vocabulary): derive
Reflecton the config, tag any string that names something with theNamesattribute, and add theFilterChoicevariant incrates/nova_editor/src/event.rs-ALL,label,stem,operands,stock, plus thefilter_choice,filter_configandfilter_config_mutarms. The matches are exhaustive, so the compiler names every one of them.
Recipe 3: add an event action
An action runs when a handler passes, in order. Everything lives in
crates/nova_scenario/src/actions/: the EventActionConfig enum and its
dispatch in actions/mod.rs, each action’s config and impl in the submodule
for what it touches (view.rs, flow.rs, mission.rs, sequence.rs,
ship.rs, spawn.rs, timer.rs).
-
Define the config struct and its
EventAction<NovaEventWorld>impl.fn action(&self, world: &mut NovaEventWorld, info: &GameEventInfo)mutates the seam and nothing else - useworld.insert_variable,world.push_objective,world.next_scenario, orworld.push_command(...)for world access. Anything needing an id -> Entity lookup queues acommands.queue(move |world: &mut World| ...)inside the pushed command, scoped withWith<ScenarioScopedMarker>(a raw id match would also hit ship sections that carryEntityId).#![allow(unused)] fn main() { #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct VariableClearActionConfig { pub key: String, } impl EventAction<NovaEventWorld> for VariableClearActionConfig { fn action(&self, world: &mut NovaEventWorld, _: &GameEventInfo) { world.insert_variable(self.key.clone(), VariableLiteral::Boolean(false)); } } } -
Add the variant to
EventActionConfig(grep forenum EventActionConfig) and the arm toimpl EventAction<NovaEventWorld> for EventActionConfig(grep forimpl EventAction<NovaEventWorld> for EventActionConfig):#![allow(unused)] fn main() { pub enum EventActionConfig { // ... VariableClear(VariableClearActionConfig), } // in the action match: EventActionConfig::VariableClear(config) => { config.action(world, info); } } -
Export the config struct from the
actions/mod.rspreludeblock. -
Make it authorable in the editor, as in recipe 2: derive
Reflect, tag the naming strings, and add theActionChoicevariant incrates/nova_editor/src/event.rs-ALL,label,stem,stock, plus theaction_choice,leaf_configandleaf_config_mutarms. OnlySequenceholds children; a new leaf action needs nothing from the tree.
Templates: the tests at the bottom of the actions/ submodules are the
pattern to copy -
despawn_action_removes_the_scoped_object_by_id (actions/spawn.rs; queued
world lookup, scoped),
hint_emphasis_actions_drive_the_resource (actions/mission.rs; resource
mutation through the drain),
objective_marker_attach_and_detach_drive_the_component (actions/mission.rs;
component insert/remove).
Each fires the action into a NovaEventWorld, drains with
NovaEventWorld::state_to_world_system, then asserts.
Recipe 4: add a scenario object kind
A scenario object is a scoped entity spawned by SpawnScenarioObject; the
kind decides its own body (or none - four of the six shipped kinds are
static). Model it on crates/nova_scenario/src/objects/beacon.rs or
salvage.rs. Do NOT model it on the asteroid: it is the least representative
kind in the directory, split across three modules with two plugins, carrying no
Health and outside the integrity graph entirely.
A kind is not required to be a physical body.
beacon.rsdeclaresRigidBody::Static(the base bundle supplies no body), andlight.rsis a pure-render kind: it splits config from component with anAddobserver so therenderflag can skip the Bevy light entirely for headless tools. Note that scene lighting itself is authored content - a scenario with noLightobject renders black, so any new example or fixture that renders needs one.
-
Add the type-name const to
crates/nova_events/src/lib.rs, besideEntityTypeNameand the other*_TYPE_NAMEvalues, and export it from that crate’s prelude. It goes there, not beside the object, because a reader that matches on it -nova_os_ui’s map,nova_gameplay- must not depend onnova_scenarioto name a kind (CONVENTIONS, Nova 5).#![allow(unused)] fn main() { /// [`EntityTypeName`] value for an authored mine. pub const MINE_TYPE_NAME: &str = "mine"; } -
Create
crates/nova_scenario/src/objects/<kind>.rs. It holds a config struct, a marker component, a<kind>_scenario_object(config) -> impl Bundlebuilder, and (optionally) aPluginfor any observers/systems the kind needs. The bundle carries the marker plus anEntityTypeName; the sharedbase_scenario_object(id, name, transform, visibility,ScenarioScopedMarker) is added by the spawn path, not here. It deliberately carries NO body - each kind declares its ownRigidBody(the asteroid addsDynamic+TransformInterpolation; four of the six kinds are static).#![allow(unused)] fn main() { #[derive(Component, Clone, Debug, Reflect)] pub struct MineMarker; #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct MineConfig { pub radius: f32, pub damage: f32, } pub fn mine_scenario_object(config: MineConfig) -> impl Bundle { ( MineMarker, EntityTypeName::new(MINE_TYPE_NAME), // ... the kind's own components ) } pub mod prelude { pub use super::{mine_scenario_object, MineConfig, MineMarker}; } } -
Register the module in
crates/nova_scenario/src/objects/mod.rs: addpub mod <kind>;, re-export<kind>::prelude::*from themod.rsprelude, and if the kind has a plugin add it inScenarioObjectsPlugin::build(likeAsteroidPlugin, which takesrender). -
In
crates/nova_scenario/src/actions/spawn.rsadd the variant toScenarioObjectKind(grep forenum ScenarioObjectKind) and the spawn arm inimpl EventAction<NovaEventWorld> for ScenarioObjectConfig:#![allow(unused)] fn main() { pub enum ScenarioObjectKind { Asteroid(AsteroidConfig), Spaceship(SpaceshipConfig), Beacon(BeaconConfig), SalvageCrate(SalvageCrateConfig), Light(LightConfig), Mine(MineConfig), } // in the spawn match: ScenarioObjectKind::Mine(config) => { entity_commands.insert(mine_scenario_object(config.clone())); } }
The new kind is now spawnable from any handler, in RON, as a
SpawnScenarioObject action:
SpawnScenarioObject(ScenarioObjectConfig(
base: BaseScenarioObjectConfig(
id: "mine_1",
name: "Proximity Mine",
position: (0.0, 0.0, -200.0),
rotation: (0.0, 0.0, 0.0, 1.0),
),
kind: Mine(MineConfig(radius: 5.0, damage: 40.0)),
))
Two surfaces author the vocabulary
A RON file is one way to write a handler; the editor’s EVENTS mode is the other,
and it draws every row it shows by REFLECTION off the same config structs. So a
config that is not Reflect is a construct the editor cannot show, and a string
field with no Names
attribute is a blank box where the panel could have offered the ids the document
actually spawns.
AN AssetRef<A> FIELD PICKS ITS OWN FILE. The panel reads the sort off the
type, so a field typed AssetRef<Image> offers the images the installed bundles
DECLARE in their resources lists, written as the dep://<mod>/<file> ref that
resolves - and marks a dep:// path no bundle ships. Do not add
#[reflect(ignore)] to an asset field a builder is meant to change: an ignored
field has no row at all.
A FIELD’S DOC COMMENT IS ITS TOOLTIP. nova_editor builds against
bevy/reflect_documentation, so the first paragraph of the /// above a field
- or above the variant a choice row stands on - is what the panel says when the pointer rests on that row. Write it for the person filling the box in, not for the person calling the constructor: a field nobody documented is a row the editor cannot explain.
What each of the four recipes owes the editor:
| Recipe | What the editor needs |
|---|---|
| Event kind | Nothing. EventConfig is a Reflect enum of unit variants and the handler’s trigger row is walked off it, so a new event appears in the list. |
| Filter | Reflect + Names on the config, and a FilterChoice variant with its stock value. |
| Action | Reflect + Names on the config, and an ActionChoice variant with its stock value. |
| Object kind | Reflect + Names on the config, an ObjectChoice variant in crates/nova_editor/src/node.rs to place it with, and the arms the compiler then asks for (glyph, preview, stage, inspect). |
stock is what the kind switch puts on a node the moment it is switched TO -
a valid config with empty ids, never a Default that lowers into something the
lint refuses.
Checklist
Whichever recipe you follow, the change is done when: the config struct derives
Clone, Debug, Reflect and the serde pair; every field says what it is for
in a doc comment; every string that names something carries its Names
attribute; every file a builder may change is an AssetRef<A> the reflection
can see; the dispatch enum has the variant; the
trait impl has the delegating arm; the type is exported from its module prelude;
the editor has the choice variant (events excepted); and (for an event)
something fires it. Then it is reachable from code-built scenarios, from a RON
data file, and from the editor.
Find it in the code
- Events:
EventConfig-crates/nova_scenario/src/events.rs; event types and theEventKindderive -crates/nova_events/src/lib.rs. - Filters:
EventFilterConfig-crates/nova_scenario/src/filters.rs. - Actions:
EventActionConfig-crates/nova_scenario/src/actions/mod.rs(submodules: flow, mission, ship, spawn, timer, view). - Objects:
ScenarioObjectKind-crates/nova_scenario/src/actions/spawn.rs; kind modules undercrates/nova_scenario/src/objects/. - The seam:
NovaEventWorld-crates/nova_scenario/src/world.rs. - What a string names:
Names-crates/nova_scenario/src/names.rs; the editor’s choices:FilterChoice,ActionChoice-crates/nova_editor/src/event.rs. - API detail:
cargo doc --open -p nova_scenario(event engine:-p nova_events).
Giving a generated ship a front
Design note, written against examples/playable/shared/wfc.rs and the
wfc_ships row it collapses.
The complaint it answers: looking at any render of the wfc_ships row, drives
point in several directions on the same hull. The generator places parts by
mating rules alone. Nothing knows a thruster belongs at the back, a bridge on
top, or guns where they can bear.
One of the recommendations below is already implemented and rendered, because it turned out to be twelve lines. The rest is a proposal.
1. The actual cause, which is narrower than “no sense of layout”
Two facts about the generator, together, produce the whole symptom.
A drive’s direction is not a choice the generator makes. The thruster
section carries ONE socket, on its forward end, and its exit normal is +Z in
its own frame. So the face it is bolted to IS the direction it exhausts. A drive
bolted to the roof fires up; one bolted to the transom fires aft. Nothing
“chooses badly” - the collapse never chooses at all, it just finds a hull face.
The mating rule is BINARY and cannot hold a fact about a whole ship.
compatible(tiles, here, face, there) reads one cell and its neighbour. “Aft” is
not a property of any pair of cells; it is a property of the grid. A binary
constraint cannot express it, ever, no matter how the tileset is authored.
So the symptom is not a missing heuristic. It is a category error: the file was asking a local rule to carry a global fact. Everything below is a way of putting that fact somewhere a local rule can see it.
Worth saying plainly what is NOT broken. Guns already point somewhere sensible, and for a reason: exit clearance forbids a muzzle whose lane is fouled, so a mount can only survive standing proud of the hull with space in front of it. Clearance is doing the “where they can bear” job already. Bays likewise - a broadside tube is a real warship, and a bay firing into its own hull is already illegal. The drive was the one part whose direction was both free and wrong.
2. What the grid already gives us to hang this on
More than expected. The generator is not orientation-blind - it is orientation-blind about parts while being quite opinionated about shape.
| Fact | Where | What it already means |
|---|---|---|
LENGTH = 11 on z | HULL_GRID | the ship’s long axis |
HEIGHT = 5 on y, KEEL_ROW = 2 | HULL_GRID, KEEL_ROW | up, and a middle |
x = 0 is the mirror plane | HULL_GRID.origin.x = 0.5 | port/starboard |
VACUUM_BOW_TAPER = 24.0 | hull_vacuum_weight | z = 0 is the BOW |
VACUUM_STERN = 9.0 | hull_vacuum_weight | the last row is sparse |
seed_keel | collapses the spine by hand | a connected structure to grow on |
keel_prototype(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 eleven cells before the generator gets a say,
and keel_prototype already puts a bridge forward of centre. The precedent for
“decide the big thing up front, let the collapse fill in around it” is in the
file, working, with a doc comment explaining why. Everything in this note is that
same move applied one more time.
What the grid does NOT give: any asymmetry in y. off_keel is
x + |y - KEEL_ROW|, symmetric about the keel row, so a hull’s deck and its
belly are weighted identically. A ship generated here has a front and a back but
genuinely has no top.
3. What comparable generators do
Read rather than re-derived. Sources at the end.
Nobody makes a local constraint carry a global fact. The consistent answer across practitioner writeups is: decide the coarse thing FIRST, by other means, then let the constraint solve fill in detail. Boris the Brave calls this driven WFC and states the cost plainly - the algorithm already starts with a set of possible tiles per cell, so filtering that set before the solve is free. His worked example is Townscaper, where the player’s painted solidity decides which tiles may go in each cell and WFC never sees a global decision at all.
Per-cell BANS, not per-cell weights. This is the distinction that matters and it is easy to get wrong. Weights bias; they do not forbid. A low-weight drive at the bow still lands at the bow eventually, and worse, tile weights in the classic formulation are global frequency hints - vary them per cell and the entropy heuristic has to move from a count to a proper Shannon entropy over the surviving weights or the cell ordering stops meaning anything. A unary domain filter has none of that cost. This is standard constraint-solving practice, not a trick: unary constraints are handled by adjusting variable domains before the solve starts.
Buckets by normal direction, after the fact. a1studmuffin/SpaceshipGenerator
(MIT), the best-known procedural spaceship, builds a hull by extruding front and
rear faces and then categorises every face by its normal and rolls detail per
category - engines on rear faces, antennae on front and top, weapons on the
sides. Two things to take: detail follows orientation, and there is a GUARANTEE
GUARD - the rear-face rule fires an engine if the roll passes or the engine list
is still empty, because a pure probability table produces engineless ships.
Regions, if bans start causing contradictions. Merrell’s model synthesis “modifying in blocks” - solve overlapping blocks with their borders constrained to what is already placed, and restart the offending block rather than the whole output - is the standard answer, and Caves of Qud ships a version of it, running WFC with different settings on subsets of the map. This is the escape hatch, not the starting point. It is worth knowing that our collapse has NO backtracking and cannot get one cheaply, so anything that raises the contradiction rate is expensive here in a way it is not elsewhere.
Grammars are not automatically the semantics fix. Shape and graph grammars express “door on the front facade” naturally, because a grammar’s scope is an oriented box. But Merrell’s own graph-grammar work targets LOCAL SIMILARITY, the same goal as WFC, and does not claim to encode front/back/top. Swapping solvers does not buy the fact; putting the fact in the right place does.
4. Ranked recommendations
Cost against effect. The first is done; the rest are not.
1. A per-part AIM, as a unary constraint. DONE, RENDERED
Part gains aim: Option<usize> - the only face this part may fire through -
and hull_domains strikes any tile that disagrees. The thruster gets
Some(AFT); everything else keeps None.
- Cost: one struct field, a four-line predicate, one line in
hull_domains. Cannot empty a domain, 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. DONE, RENDERED
seed_stern hand-collapses two cells before the roll: a hull block beside the
last keel cell, and a nozzle bolted to its aft face. The mirror makes that a pair
either side of the centreline. seed_keel now stops one cell short of the
transom so the seam cell beside the drive is free - a keel cube there would press
a socket into the drive’s blind flank.
- Cost: fifteen lines, in the shape of the function above it.
- Effect: every ship has an engine at the back of it, guaranteed, and the seeded pair MAKES the aft-facing surface the roll then fills the transom around. Item 1 says where a drive may not go; it cannot conjure the place where it may. The two are one change.
- Measured, 12 seeds, with the drive weight taken 3.2 -> 6.4 to pay for the rule: hull 1774 -> 2000, drives 258 -> 102, bays 96 -> 74, mounts 92 -> 62, bridges 46 -> 52. Per ship that is 21.5 scattered drives becoming 8.5 in one stern bank. Weapons are down about a third, which is a weight question and the note under item 6.
- Rendered at a matched camera over the default row. The ships gained a readable stern: a bank of nozzles all firing the same way, with the hull running forward from it. This is the single biggest change to how the row reads of anything in this note.
3. Give the hull a deck and a belly. MEASURED, NOT LANDED
off_keel is symmetric in y, so a ship has no top. Weight the two directions
differently - |y - KEEL_ROW| times DECK_TAPER above and BELLY_TAPER below -
and the hull fills out above the keel and tapers under it.
- Cost: one line and two constants.
- Effect, measured over 12 seeds at 0.6 / 1.6: hull 1774 -> 1968, drives 258 -> 212, bays 96 -> 78, mounts 92 -> 90. Rendered, and I did not land it. It makes the ships denser and blockier rather than more oriented: one of the three read distinctly better, one read as a brick, and the owner has said on the record that the pointy, faceted vocabulary is wanted. A silhouette change is a taste call for the owner, not a bug fix, and this one is not clearly an improvement. Left here with its numbers so the next person does not have to measure it again.
- If it IS wanted, the honest version is probably not this: it is a
y-dependent keel row, or a superstructure seeded on top of the keel the way item 2 seeds the stern. Which is item 4.
4. Seed a superstructure, not just a bridge cell
keel_prototype puts the controller at y = KEEL_ROW, mid-height, buried inside
the hull. A bridge that reads is a bridge you can SEE: on top, forward of centre,
standing proud.
- Cost: a second seeded run of cells, in
seed_stern’s shape - two or three hull cells 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 is real: a seeded tower is a stud, and
erode_studsexists to take those off. It would need the same treatment the keel gets - either enough seeded neighbours to passSPIKE_SUPPORT, or an exemption. Budget a couple of hours, not ten minutes.
5. Zone the grid, and give each zone its own part list
The general form of items 1-4: split z into bow / midships / engineering bands
(the vacuum taper already computes the signal) and ban part families per band -
no drives forward of the engineering band, no bays in it, bridges only in
midships-top. Same mechanism as item 1, a table instead of a formula.
- Cost: a band function and a table. Maybe forty lines.
- Effect: the ships get an internal LAYOUT rather than a uniform texture, which is the difference between “a hull with parts on it” and “a hull with a bow, a waist and an engineering section”.
- Do this only if items 1-4 are not enough. Every band is more domain filtering, and filtering shrinks the solution space while local propagation still cannot see a dead end coming. This collapse has no backtracking. If bands start producing contradictions the answer is modifying-in-blocks per band, and that is a real piece of work.
6. Re-tune the weights, once, after all of the above
The record already says weights have to be tuned together and read together, because the parts compete with each other rather than only with vacuum. Items 1 and 2 changed the competition: drives now bid for a small set of aft cells instead of every exposed face, so the surface freed up went to hull. Bays and mounts came down a third and nobody asked them to.
- Cost: a sweep, and the harness for it exists (
--ships 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.
5. What I would NOT do
Do not put the fact in the tileset. It is tempting to author two thruster prototypes, an “aft drive” and a “manoeuvring thruster”, and let mating sort them out. It cannot: mating is binary and the distinction is global, so both would still land anywhere. It would also put a generator concern into shipped catalog content that the editor and every scenario have to carry.
Do not add a scoring-and-rejection pass. Generate N ships, score each for “engines at the back, bridge on top”, keep the best. It is the obvious answer and it is a trap: the cost is N collapses per ship, the failure mode is that the SCORER quietly becomes the real designer while the generator’s own rules stop being the thing that produces the design, and any weighting inside it is invisible in a way a per-part field is not. The one practitioner example I found of scoring generated output optimises the generator’s PARAMETERS rather than picking among outputs, which is a different and much cheaper shape. If we ever want this, that is the version to want.
Do not rewrite the collapse as a grammar or a graph rewrite. It would express front/back/top naturally, and it would throw away the thing this generator is actually for: the adjacency rules ARE the catalog’s link points, so a hull the generator draws is a hull a player could have built. That property is the whole point of the example and no amount of layout sense is worth it.
Do not make the exit-clearance rule directional. It is correct as it stands,
it is shared with the editor, and a version of it that knows about “aft” would
put a generator’s taste inside a rule a player’s ship is judged by. Aim belongs
in Part, where it is one file’s opinion; clearance belongs in nova_ship,
where it is physics.
Do not reach for backtracking. Every item above is a unary domain filter and none of them can empty a domain, because vacuum is compatible with everything. That property is worth defending: a collapse that cannot fail needs no restart loop, no retry counter and no failure budget. Any proposal that costs it should have to argue for itself.
Sources
Verified from the primary source:
- 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