Introduction
The Nova Protocol developer book: how to run the project, how to extend it,
and where things live. Source lives in docs/ at the repo root and builds
with nix develop --command mdbook build.
This book is the LOOKUP layer: search a concept (the search icon, top left),
land on a Concept index row, and leave knowing which
crate, files and entry symbol to open. For API detail past that, run
cargo doc --open -p <crate> locally - the book routes, rustdoc documents.
Reading order for a first visit:
- Building and running - toolchain, everyday commands, examples, the web build, releases.
- Project tour - the crate map and where to change X.
- The Architecture chapters for depth, then the Extending guides for your change.
Before quoting a millisecond at anyone, read Measuring performance.
What this book is not:
- API detail. That is rustdoc:
cargo doc --open, run locally. Every crate exposes aprelude; the book names modules, rustdoc documents them. - Player or creator documentation. Players read the wiki; mod authors read Create on the site. Keeping docs in sync maps every surface.
Concept index
The routing table. Search a concept (the search icon or S), open the named
files, start reading at the entry symbol. Paths are repo-relative; every row
was verified against the tree. For API detail run
cargo doc --open -p <crate> locally - every crate exposes a prelude. For
prose depth, follow the chapter linked at each group heading.
Boot and frame flow
Depth: Architecture.
| Concept | Crate(s) | Key files | Entry symbol | Orientation |
|---|---|---|---|---|
| App boot, app assembly, plugin order | nova_core | crates/nova_core/src/lib.rs | AppBuilder | build() wires the whole plugin stack; game binary + CLI flags in src/main.rs. |
| Game states, pause | nova_gameplay, nova_menu | crates/nova_gameplay/src/lib.rs, crates/nova_menu/src/pause.rs | GameStates, PauseStates | State enums + GameMode live in gameplay; ESC overlay, toggle_pause and the clock freeze in menu. |
| Frame flow: Update vs FixedUpdate | nova_ship, nova_gameplay | crates/nova_ship/src/lib.rs, crates/nova_gameplay/src/plugin.rs | SpaceshipSystems | NovaShipPlugin chains the set brackets identically in both schedules; avian physics runs in FixedPostUpdate. Which schedule your system goes in: the Architecture chapter. |
| Units: meters, and the one engine seam | nova_events, nova_ui | crates/nova_events/src/units.rs, crates/nova_events/src/scale.rs, crates/nova_ui/src/units.rs | Meters, METERS_PER_UNIT | Everything authored, named as a constant or shown to a player is SI; Bevy, avian, meshes and the build grid count in world units at 10 m each. to_engine/from_engine are the only crossing and every call site is a documented boundary; nova_ui::units formats a readout without converting. The whole rule, and what stays engine-side: Architecture. |
| Asset loading gate, mod merge | nova_assets | crates/nova_assets/src/plugin.rs, crates/nova_assets/src/merge.rs | GameAssetsPlugin | GameAssetsStates gates entry to the menu/game; register_bundles merges base + enabled mods into the Game* resources. |
The ship
Depth: Ship sections internals.
| Concept | Crate(s) | Key files | Entry symbol | Orientation |
|---|---|---|---|---|
| Ship building from sections | nova_scenario, nova_ship | crates/nova_scenario/src/objects/spaceship.rs, crates/nova_ship/src/sections/base_section.rs | insert_spaceship_sections | Observer on the ship root spawns one child per SectionKind config, resolving prototypes; SpaceshipConfig is the authored shape. |
| Section integrity: damage, disable, destroy | nova_gameplay | crates/nova_gameplay/src/integrity/mod.rs, crates/nova_gameplay/src/damage.rs | NovaIntegrityPlugin | Health store and the disable/destroy chain; typed DamageType + the travel rule (apply_damage, pierce_remainder). Health decides WHEN a body dies; the two readings below decide what it looks like. |
| How far gone a body looks | nova_gameplay | crates/nova_gameplay/src/integrity/erosion.rs | DamageLevel | One scalar, 0..1, derived from the entity’s OWN Health. Grades every whole-body effect. |
| Where a body was hit (marks, carve cost, merge) | nova_gameplay | crates/nova_gameplay/src/integrity/carve.rs | DamageMarks, mark_radius | Spheres in the body’s local frame, priced by what the hit ABSORBED at DAMAGE_PER_UNIT_VOLUME (8 hp per cubic WORLD unit, so per 1000 m^3 - an inverse volume, and engine-side for that reason); record_blast_marks cuts one crater per body. |
| Authored per-section damage looks | nova_ship | crates/nova_ship/src/sections/damage_effects.rs, damage_cracks.rs, damage_sparks.rs, damage_plume.rs | DamageEffects, fit_damage_effects | Cracks/Sparks/Plume, one component each, default [Cracks]. No ship section ever loses geometry. |
| Carve debris: dust and severed pieces | nova_gameplay | crates/nova_gameplay/src/integrity/spew.rs, crates/nova_gameplay/src/integrity/chunk.rs | CarveSpew, ShardLook, spawn_carved_chunk | Shards are keyed on the WEAPON CLASS - kinetic and pierce chip, explosive does not; only a cut that SEVERED material spawns a real body, floored at CHUNK_MIN_VOLUME. |
| Severing, wreck fragments, structural collapse | nova_ship | crates/nova_ship/src/sections/integrity.rs | ShipIntegrityPlugin | Builds the section graph, splits disconnected structure (sever_disconnected_structures -> ShipWreckFragmentMarker), runs cascade_structural_collapse. |
| Neutralization (combat-dead) | nova_gameplay | crates/nova_gameplay/src/integrity/neutralize.rs | NeutralizedMarker | An armed ship that loses all weapons, or the flight computer it had, fires OnNeutralizedEvent; an unarmed ship instead goes this way on the last working thruster it ever had (HadThruster); the hull may survive. |
| Neutralized brake (a beaten hull stops on its own RCS) | nova_ship | crates/nova_ship/src/flight/neutralized_brake.rs | NeutralizedBrakeType, neutralized_brake_system | A neutralized non-player, non-station root brakes toward world rest by writing its RcsIntent, paid from its own RcsBudget; no engine needed. Docked, no computer, RCS off, empty, or a well past RCS_GRAVITY_AUTHORITY ends it for good; AI writers and helm orders refuse the hull. |
| Procedural hulls: the collapse, the plan | nova_wfc | crates/nova_wfc/src/lib.rs, crates/nova_wfc/src/collapse.rs, crates/nova_wfc/src/tiles.rs, crates/nova_wfc/src/plan.rs | TileSet, WfcPlan | TileSet::build reads a WfcPlan against the section catalog; hull() collapses one seed into a ShipDesign and runs it through the game’s own content lint before handing it out. The plan is CODE, not content: WfcPlan::standard_hull is the shipped one, and each caller (the editor’s Generate block, wfc_arena) builds its own from that plus its own UI rows. Grid geometry, seeds and zones: crates/nova_wfc/src/grid.rs. Whether a part can stand on the grid at all is nova_ship::cell_grid_fit, and nova_ship::oriented_part reads it as grid cells at one cube rotation (crates/nova_ship/src/sections/cell_grid.rs); the open-world part snapshot and ship layout (crates/nova_world_base/src/ship_layout.rs) read both too. |
| Ship skins, styles, greebles | nova_ship | crates/nova_ship/src/sections/shell_skin.rs, crates/nova_ship/src/sections/skin_style.rs, crates/nova_ship/src/sections/skin_surface.rs, crates/nova_ship/src/sections/greeble_solver.rs, crates/nova_ship/src/sections/skin_decor.rs | ShipSkinPlugin | Cladding derived from structure; styles resolve by id (ShipStyleConfig, GameStyles). A style authors a two-surface StylePalette, its fixtures and greeble_rules (GreebleRules: link types, greebles with four link points, near terms, caps). solve_decor solves the rules over the skin’s quad graph (skin_surface, solve_greebles) with the seed greeble_seed(style id). |
| Turrets, aiming | nova_ship | crates/nova_ship/src/sections/turret_section/mod.rs, crates/nova_ship/src/sections/turret_section/aim.rs | TurretSectionPlugin | Authored joint tree; lead-intercept aim (update_turret_aim_point, muzzle_on_target); arcs and firing in sibling files. |
| Torpedoes, point defense | nova_ship | crates/nova_ship/src/sections/torpedo_section/mod.rs, crates/nova_ship/src/input/point_defense/mod.rs | TorpedoSectionPlugin, SpaceshipPointDefensePlugin | Bay + guided round lifecycle (TorpedoGuidance, TorpedoBlast); PD splits target assignment from mount authority (borrowed player mounts). |
| The spinal lance: commit, charge, shot, recoil | nova_ship | crates/nova_ship/src/sections/railgun_section/mod.rs, crates/nova_ship/src/sections/railgun_section/firing.rs | RailgunSectionPlugin, RailgunCharge | The hull aims it. charge_and_fire_railgun runs the whole cycle in one fixed-step system; the slug is a Pierce round with layers: u32::MAX, so slug_power alone bounds it. The AI’s crude commit envelope: input/ai/railgun.rs (AIRailgun). |
| The rake: a slug that widens its cut | nova_gameplay, nova_ship | crates/nova_gameplay/src/rounds.rs, crates/nova_ship/src/sections/railgun_section/firing.rs | RoundRake, sweep_raking | Authored rake_radius inserts RoundRake; three passes per step - the tip arms bodies it hits, a sphere trailing it sweeps each armed body’s colliders, contacts charge by depth then axis outward out of the one budget. Measured in examples/systems/system_railgun_lance.rs. |
| Docking: the candidate pair, the joint, the break | nova_ship | crates/nova_ship/src/sections/docking_section/port.rs, crates/nova_ship/src/sections/docking_section/admission.rs, crates/nova_ship/src/sections/docking_section/connection.rs | DockingPorts, DockAdmission, DockingConnection, DockingSystems::Release | DOCK names two hulls; a new dock needs both a port candidate (face gap between RETRACTED faces, opposed axes, relative speed, graded on the STRICTER of the two envelopes and tie-broken on EntityId) and DockAdmission, which a ship target refuses while Hostile, Enemy, answering fire, or fighting (a neutralized target always admits) and a station target refuses while every port is held, or is a living station on the Enemy side or hostile to the ship (station_admission; a derelict or neutralized station and a station the player owns, OwnedStationMarker, admit on any side, and a station’s refusal reaches the HUD as a notice through FlightVerbHints::dock_refusal). One FixedJoint with an explicit anchor and basis holds the pose they met in; nothing writes a velocity. A ship holds one connection; a station is a hub holding one per port (StationDock/StationDocks, DockedStation), and the station with every docked hull is one assembly. One hull drives the assembly: DockingConnection.helm (DockedHelmType: Neutral, Held(player), or Station(station) for a living station, which refuses HELM; a derelict or neutralized hull, ship or station, hands the helm to the player at capture) picks it, DockedShip.drives/DockedStation.drives gates every actuator on the other roots, and DockedAssembly gives the driver the combined mass, centre of mass and inertia. DOCK again from either hull, or the player provoking a docked Neutral past NEUTRAL_RETALIATION_THRESHOLD, are the pilot-facing ways out. |
| The bore sight | nova_hud | crates/nova_hud/src/bore_sight.rs | BoreSightPlugin | The lance’s line of fire in world space, walked through the same pierce_remainder as the round; a ring per section the shot would destroy; gated on WeaponsHot, dimmed on an empty magazine. |
| Flight autopilot verbs (STOP, GOTO, ORBIT, MATCH, the AI DOCK approach), PD attitude controller | nova_ship | crates/nova_ship/src/flight/state.rs, crates/nova_ship/src/flight/autopilot.rs, crates/nova_ship/src/flight/dock_corridor.rs, crates/nova_ship/src/sections/controller_section.rs, crates/nova_ship/src/physics/pd_controller.rs | ShipCapabilities, Autopilot | The ship ROOT carries what it is permitted to do; the flight layer flies the maneuvers (NovaFlightPlugin); PDControllerPlugin is the attitude loop, and a controller section is the attitude hardware it needs. MATCH is AutopilotAction::MatchTarget, flown on RCS alone. |
| Leg detours around rocks and planetoids | nova_ship | crates/nova_ship/src/flight/navigation.rs | plan_leg, DetourPolicy | The sphere-detour planner every GOTO and GotoPos leg asks each tick, and the AI formation leg in its leader’s frame. It holds no policy: the caller states the clearance in a DetourPolicy and gets a corner back. |
| Radar locking, targeting | nova_ship | crates/nova_ship/src/input/targeting/mod.rs, crates/nova_ship/src/input/targeting/state.rs | SpaceshipTargetingPlugin | Radar search writes the two sticky lock slots on the ship root: TravelLock, CombatLock. |
| Gravity wells | nova_gameplay | crates/nova_gameplay/src/gravity.rs | GravityWell | Inverse-square wells with an SOI cutoff; only anchored planets and authored anchors publish one. Ships and asteroids feel their pull. |
Scenario and modding
Depth: Scenario engine.
| Concept | Crate(s) | Key files | Entry symbol | Orientation |
|---|---|---|---|---|
| Scenario engine, mission scripting | nova_scenario, nova_events | crates/nova_scenario/src/lib.rs, crates/nova_events/src/engine.rs | NovaScenarioPlugin | A scenario is handlers = event + filters + actions over NovaEventWorld; the generic queue/dispatch (EventHandler) is nova_events. |
| Scenario events, filters, actions (modding events) | nova_scenario | crates/nova_scenario/src/events.rs, crates/nova_scenario/src/filters.rs, crates/nova_scenario/src/actions/mod.rs | EventConfig, EventFilterConfig, EventActionConfig | One dispatch enum each; actions fan out to the flow/mission/sequence/ship/spawn/timer/view submodules beside mod.rs. |
| Scenario variables, expressions, watches | nova_scenario | crates/nova_scenario/src/variables.rs, crates/nova_scenario/src/world.rs | VariableExpressionNode, NovaEventWorld | Typed literals + expression tree (VariableConditionNode for filters); watches sample typed world queries into variables. |
| Scenario objects (asteroid, planet, spaceship, beacon, crate, light, anchor) | nova_scenario | crates/nova_scenario/src/objects/mod.rs, crates/nova_scenario/src/actions/spawn.rs | ScenarioObjectsPlugin, ScenarioObjectKind | One module per kind under objects/; the spawn action dispatches on the kind enum. |
| Scenario loading, lifetime scoping, teardown | nova_scenario | crates/nova_scenario/src/loader/mod.rs, crates/nova_scenario/src/loader/lifecycle.rs | ScenarioLoaderPlugin | LoadScenario/UnloadScenario observers; everything tagged ScenarioScopedMarker dies at teardown; scenario_is_live gates the ship sets. |
| Mod formats, bundles, portal | nova_mod_format, nova_modding, nova_assets | crates/nova_mod_format/src/lib.rs, crates/nova_modding/src/lib.rs, crates/nova_assets/src/portal/mod.rs | BundleManifest, Content, PortalPlugin | Engine-free serde wire types -> RON asset loaders -> portal fetch/verify/install. The static portal is generated by scripts/gen-portal.py over webmods/. |
| Content generation (base RON, builders, lint) | nova_authoring | crates/nova_authoring/src/cli.rs, crates/nova_authoring/src/generation.rs | cli::main | cargo run content gen serializes the base_content builders into the committed base *.content.ron; content lint validates any content tree. Never hand-edit generated RON. |
Interface
| Concept | Crate(s) | Key files | Entry symbol | Orientation |
|---|---|---|---|---|
The TAB interface and the ; command modal | nova_command, nova_interface | crates/nova_command/src/commands.rs, crates/nova_interface/src/pane.rs, crates/nova_interface/src/lib.rs | InterfacePlugin, InterfaceSystems, CommandsSystems | Pure command language and terminal model in nova_command (COMMAND_CATALOG, CommandTerminal); the Tab interface with its Map, Ship and Inventory panes, and the ; CRT modal, in nova_interface. |
| Keybinds: the action table, rebinding, the capture | nova_input, nova_menu | crates/nova_input/src/registry.rs, crates/nova_input/src/poll.rs, crates/nova_menu/src/settings.rs | InputBindings, InputSources | One table of named actions; each owner registers its own defaults, every rig is BUILT from it, and every rebind surface reads it. Settings’ Controls tab takes the next press through InputSources; overrides persist in settings_store. |
| Flight HUD widgets | nova_hud | crates/nova_hud/src/lib.rs | NovaHudPlugin | One module per instrument (crosshairs, ammo, markers, comms, keybind dock); reads the ship, never drives it. |
| Main menu, menu backdrops | nova_menu | crates/nova_menu/src/lib.rs, crates/nova_menu/src/ambience.rs | NovaMenuPlugin | The backdrop is a role: Backdrop scenario from the menu’s shuffled rotation (MenuBackdropRotation); a backdrop’s BackdropDone makes advance_menu_backdrop cut to the next one. NOVA_MENU_BACKDROP pins the first one for capture. |
| Training handbook: lessons, field notes, progress | nova_training, nova_menu | crates/nova_training/src/catalog.rs, crates/nova_training/src/validate.rs, crates/nova_menu/src/training.rs, crates/nova_menu/src/training_store.rs | TrainingCatalog, TrainingProgress | A lesson is CONTENT (Content::Lesson): register_bundles merges base + mods into TrainingCatalog, and the menu draws whatever it is handed. Practice may only name a role: Lesson scenario and proven_by any launchable one; both are lint_lesson, at lint and then at load. Progress is a SECOND file beside settings.ron (TrainingProgressPlugin): opening a lesson reads it, and only a Victory on a scenario its proven_by names completes it. Field notes are derived from the same lessons (facts.rs) and drawn on three surfaces - the menu’s bottom-left notice corner beside the first-launch offer, the boot screen (compiled boot_field_notes, before any catalog exists) and the scenario load screen, which prefers a note from a lesson that names the scenario. Each corner notice carries its own switch (TrainingPromptSetting, FieldNoteSetting): the card writes it, Settings > Interface brings it back, and neither touches the loading screens. A lesson’s demonstration is real footage at assets/base/training/<id>.webp, recorded by the lesson_* capture producers (scripts/capture-lesson-media.sh) and cut by the grid the LESSON authors; every base lesson has a producer, and one authored without one keeps scripts/gen-lesson-media.py’s placeholder, which never redraws over captured footage. |
| Scenario roles | nova_scenario | crates/nova_scenario/src/loader/mod.rs, crates/nova_scenario/src/lint/scenario.rs | ScenarioRole | Chapter (a level, the picker row), Backdrop (menu scenery, poses its own camera, reports BackdropDone), Lesson (a handbook practice range). A campaign may list only chapters that need no New Game session. |
| Station visits and trade profiles | nova_world_base, nova_gameplay | crates/nova_world_base/src/station_visits.rs, crates/nova_gameplay/src/station_visits.rs, crates/nova_gameplay/src/trade_profile.rs | StationVisits, TradeProfileDesign | drive_station_visits is the one writer of StationVisits: a generated AI ship with a profile binds a nearby living station, docks, runs its trades and posts its jobs once (the receipt), then departs. A profile is content (GameTradeProfiles); the trade plans against both holds and purses and never creates cargo or credits. |
| Scenario world | nova_scenario, nova_world_base | crates/nova_scenario/src/loader/mod.rs, crates/nova_world_base/src/lib.rs | ScenarioWorldType | Void (the default: authored objects only) or Generated((seed, bounds)): the base generator streams around the scenario’s one player ship. seed: None takes the New Game seed (OpenWorldSession), so only New Game and Load start it; a fixed seed is a picker row. |
| Shared UI themes and widgets | nova_ui | crates/nova_ui/src/theme/mod.rs, crates/nova_ui/src/widget/mod.rs | NovaUiPlugin | Moddable UI themes (UiThemeConfig -> ActiveUiTheme) and the widget factories every UI-drawing crate consumes. |
| Ship editor, link points, placement | nova_editor, nova_ship | crates/nova_editor/src/lib.rs, crates/nova_editor/src/snap.rs, crates/nova_ship/src/sections/link_points.rs | NovaEditorPlugin | Editor build scene; snap_placement mates socket frames; the editor solver decides or refuses a drop. |
| Mod project, Mod Hub, package, import, asset bench | nova_editor, nova_assets | crates/nova_editor/src/project.rs, crates/nova_editor/src/asset.rs, crates/nova_editor/src/project/store.rs, crates/nova_editor/src/project/package.rs, crates/nova_editor/src/ui/hub.rs | ModProject | One mod held in memory: KINDS lists its content, one asset is open, package.rs owns the zip and the import checks. |
Tooling
Depth: Automation harness, Measuring performance and Building and running.
| Concept | Crate(s) | Key files | Entry symbol | Orientation |
|---|---|---|---|---|
| Automation harness, autopilot scripts, screenshot capture | nova_autopilot, nova_debug | crates/nova_autopilot/src/autopilot.rs, crates/nova_debug/src/harness.rs | AutopilotPlugin | Env-armed step driver (NOVA_AUTOPILOT, NOVA_CAPTURE); Nova presets and the shoot capture idiom live in the debug harness module. |
| Probe, run reports, what an example claims | nova_probe, nova_probe_cli | crates/nova_probe/src/capabilities/mod.rs, crates/nova_probe/src/contract.rs, crates/nova_probe_cli/src/native.rs | NovaProbePlugin | One bundle wires every capability; probe run/scenario/report (subcommands of the game binary, debug feature) spawn and grade runs. Web capture app: crates/nova_perf_web/src/main.rs. |
| Frame cost, scene census, the capture window | nova_probe | crates/nova_probe/src/capabilities/frametime.rs, framecost.rs, census.rs | nova_frametime, nova_framecost, nova_census | Wall-clock deltas over a fixed window, plus where the milliseconds went and what the scene contained. Knob table: cargo doc -p nova_probe. |
| World-state snapshot (read the world, not a render) | nova_probe | crates/nova_probe/src/capabilities/snapshot.rs | nova_snapshot, probe_snapshot | One JSON object per snapshot: ships, sections, fixtures, weapon state, rounds in flight. Sorted and rounded, so two snapshots of one frozen frame are byte-identical. |
| WFC arena: benching generated hulls | examples, nova_wfc | examples/playable/wfc_arena.rs, examples/playable/wfc_arena/stamps.rs | arena_tiles | Fields collapsed hulls against each other. The collapse itself is nova_wfc (see The ship); the arena names a spinal gun as its plan’s keel.bow_gun and stamps a large-drive stern no single plan can express. Lobby, pause and result flow sit beside it in examples/playable/wfc_arena/. |
| Debug tooling (Nova Debug window, overlays, F12 screenshots) | nova_debug | crates/nova_debug/src/lib.rs, crates/nova_debug/src/hud/mod.rs | DebugPlugin, DebugHudPlugin, DebugOverlays | Compiled only under the debug feature; F11 opens the Nova Debug window (freezes flight in PauseStates::Debug), F12 screenshot, --norender, --debugdump. |
| Web preview, this book at /dev/ | scripts, web | scripts/serve-web.sh, scripts/preview-web.sh, web/webpack.config.js | serve-web.sh | Live-serves site + /play/ + /mods/ + this book at /dev/, all watched; the preview script builds the static deploy shape. |
Development
Toolchain
- Rust nightly, pinned by
rust-toolchain.toml(with rustfmt + clippy). - NixOS:
nix developgives the toolchain, thewasm32-unknown-unknowntarget, all system libs Bevy needs (udev, alsa, vulkan, X11/wayland),trunk, andsccache(see fast worktree builds below). Without Nix, install those yourself. Barecargois not on PATH under Nix: run every cargo/rust command vianix develop --command <cmd>(the commands below assume you are insidenix develop). - Playing it, as opposed to working on it:
nix runbuilds and launches the packaged game with no checkout and no development shell. See The nix package.
Everyday commands
cargo run # the game (boots into the main menu)
cargo run -- --scenario tutorial # the game, straight into one scenario
cargo run --features dev # + debug tooling (inspector, wireframe)
cargo run --example system_scenario_grammar # run an example
cargo build --release # release profile: opt=s, lto, stripped
cargo check && cargo fmt # before committing
cargo test --workspace # full suite (CI runs this; skip locally unless asked)
cargo run content lint # validate content: refs + ship parts + balance + input overlaps (also: gen)
cargo run --features debug probe run system_player_path # run-harness check (correctness + perf)
Notes that keep the suite honest and fast:
- Use
cargo test --workspace, never barecargo test: unit tests live in the member crates, so the bare form runs almost nothing and gives false comfort. cargo testtakes ONE filter and one-pper invocation; separate runs for separate filters or packages.- For a timed headless example run, build first, then time only the run
(
cargo build --example X --features debug, thenNOVA_AUTOPILOT=1 timeout N cargo run --example X ...). A cold build inside the timeout burns the window. - Struct-field changes:
cargo check --workspace --all-targets, or examples and tests stay silently broken.
The dev profile uses opt-level = 1 for our code, 3 for dependencies: slow
first build, fast iteration. split-debuginfo = "unpacked" +
debug = "line-tables-only" keep link-time RAM around 20 GB instead of 40
(one Bevy-sized binary per test/example target); set debug = true temporarily
if you need a debugger.
Worktree builds (fast via sccache): a fresh sprout worktree starts with an
empty target/, but the devshell wires sccache as RUSTC_WRAPPER (with
CARGO_INCREMENTAL=0, which sccache requires) so it does NOT pay a full cold
build. sccache caches each rustc invocation’s output keyed by a hash of the
source content plus flags plus compiler version, in a shared cache
(~/.cache/sccache). Unchanged deps (bevy, avian, the whole pinned tree) are
100% cache hits across worktrees; only changed nova_* crates recompile.
Measured on 2026-07-21 (game binary, quiet host):
| build | wall clock | sccache stats |
|---|---|---|
| cold (empty cache) | ~6m45s (405s) | 517 misses / 0 hits |
warm (cargo clean, same source) | ~38s | 517 hits / 0 misses (100%) |
The warm number is what a fresh sprout worktree gets once the shared cache is warm. Recipe from a new worktree:
cd "$(sprout new <branch>)"
nix develop --command cargo build # warm-cache: seconds, not minutes
nix develop --command sccache --show-stats # confirm the hit rate
Still do NOT point CARGO_TARGET_DIR at another checkout’s cache: cargo keys
fingerprints on crate name + version + features + profile + rustc, NOT the
source path, so two checkouts alias each other’s artifacts in a shared dir and
a worktree binary can silently link another checkout’s code (the stale-binary
incident). Each worktree keeps its OWN target/; sccache is the SAFE way to
share compilation because its cache key IS the source content - there is no
path where a worktree links code from different source. That content-keying is
also why sccache is transparent to CI: an empty cache is just a cold build.
The devshell sets CARGO_INCREMENTAL=0 shell-wide (sccache is incompatible
with incremental). This costs the main checkout’s iterative edit-rebuild loop
its incremental speedup; the fresh-worktree-per-task agent workflow only ever
does cold-shaped builds, so it is pure win there. A sprout-scoped variant
(export the wrapper only in sprout shells, keep the main checkout on
incremental) is possible as a nix.dotfiles follow-up if the main-checkout
iteration cost bites.
Features
debug- the wholenova_debugplugin (inspector, wireframe, overlays) plusbevy/track_location.dev- alias fordebug.trace-bevy/trace+bevy/trace_chromefor span traces; the probe harness builds--features debug,tracewhen it needs one.
Debug tooling
cargo run --features dev compiles in nova_debug’s DebugPlugin
(crates/nova_debug/src/lib.rs), which adds the Nova Debug window and the
dev overlays. F11 (DEBUG_TOGGLE_KEYCODE) opens and closes the window
(crates/nova_debug/src/hud/mod.rs); Escape also closes it. In flight it
enters PauseStates::Debug: the clocks stop, flight input is off and the
cursor is free. Over the pause menu, the TAB interface, the command modal or
the main menu it opens without changing the state. The window has one
checkbox per overlay (physics, wireframe, gravity, sections, the ammo number;
the DebugOverlays resource, all off at boot, kept after the window closes),
Cheats and Command sections, an Entities section and a Raw section with every
entity, resource and asset. Cheats and Command drive the game’s command shell
(crates/nova_debug/src/hud/console.rs): the prompt is the CRT’s prompt with
its scrollback, TAB completion and history, and each cheat button submits a
command line with the player ship’s id, so an unarmed run shows the shell’s
refusal until “Arm cheats” (cheats enable). While a field in the window has
the keyboard, the window claims InputMode::Insert, so ; types a semicolon.
Entities lists the top-level entities by sector (the observer’s cell first)
or by type (crates/nova_debug/src/hud/entities.rs). An entity under a
sector root is in that root’s cell; a top-level body in a streaming world is
in the cell its position falls in, tagged top-level; a fixed scene is one
Scene group. A ship’s sections show under the ship’s row.
The overlays include the F3 diagnostics layer
(crates/nova_debug/src/diagnostics.rs, DiagnosticsPlugin, added by
DebugPlugin): labels over world points, a short panel, a row of key
hints and the DiagnosticsGizmos group. F3 or its checkbox flips
DebugOverlays::diagnostics; Shift+F3 or the detail checkbox flips
DebugOverlays::diagnostics_detail, which adds the full value list. An
example writes DiagnosticsPanel::label next to the thing it measures,
line for a few short facts, detail for the rest, and hint(key, action)
each frame the action is possible. A hand-played example adds
DiagnosticsHintPlugin::new(intro), which turns the layer on at start and
fades its intro and the toggle keys a few seconds into play, and draws in
DiagnosticsSystems; such an example needs the debug feature
(required-features in the root Cargo.toml). 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 any overlay, 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.1/src/lib.rs:406), so RUST_LOG=nova=trace restores the
per-item detail without unmuting wgpu and naga.
A headless run additionally clamps three bevy diagnostics it provokes by
construction - the missing render app, CompressedImageFormatSupport, and
gizmos noticing there is no RenderApp. All three are unreachable when a render
sub-app exists, so a rendering run keeps those targets at their normal level.
Examples
examples/ exercises one subsystem each, end to end; this repo prefers
runnable examples over isolated unit tests. The examples live in purpose
directories (bevy-repo style: category dirs, plain slug names), and the
[[example]] catalog in the root Cargo.toml (autoexamples = false) is the
single source of truth, listed in curriculum reading order.
The category contract
A category is not a folder - it is a promise about WHO an example is for. Pick the category by its audience, not by what it happens to spawn.
| Category | Who it is for | What probe does with it | Disqualifies an example |
|---|---|---|---|
playable/ | A HUMAN: somebody loads it and does the thing it demonstrates, through an affordance wired outside the NOVA_AUTOPILOT gate | runtime contract decides; native trace is automatic | its only affordance is the free-fly camera every cameraless scene already gets |
systems/ | The PROBE: a behavior staged and asserted, every claim named on the invariant roster | runtime contract decides; native trace is automatic | its product is a frame for human eyes rather than a verdict |
screenshots/ | The WEBSITE and the wiki: frames, webm loops, posed lineups, the frame-time baseline | runtime contract decides; native trace is automatic | its verdict is an assert, or a human is meant to drive it |
The test between playable/ and the other two: would a human loading this
expect to DO something? If the name promises a verb, it owes the verb. The test
between systems/ and screenshots/ is what a run PRODUCES - an assertion, or
a picture.
There are THREE categories and there is no fourth: an example that does not fit
one of them is miscategorised, not a new kind. Autopilot in a playable/
example is a SECOND driver, for captures and for the run gate - never the only
one.
Run policy is DECLARED by the example at runtime, through the probe plugins it
wires (nova_probe::contract); probe reads it back from probe-contract.json.
Every cataloged example is spawned (--all subtracts nothing), and one that
declares no capability grades UNPROBEABLE - the sanctioned opt-out, gated on
its smoke checks alone. The grading detail lives under
Run verification (probe); what each category proves
is the table above plus the per-block comments in the root Cargo.toml, and
review enforces it.
The catalog and the harness
What is on disk today, in reading order:
playable/- what a person loads and works. The hulls first:carve_asteroids(fly a PDC rig at a row of shipped-size rocks and hole one by hand) andwfc_arena(a match bench for wave-function-collapse hulls, with a lobby, a pause menu, a result board and a--ship TEAM:playerslot that puts you in one of them). Then the benches and galleries, each with its own keys:wfc_ships(Rre-rolls the collapsed row),shape_benchandblock_bench(Lcycles the style,Cstrips the cladding),railgun_wake_bench(the slug’s ionized wake at three speeds,1-3pick the lifetime policy, the arrows tune it,[]slow it down),greeble_catalog(a selection ring, a focus turntable, pedestal and cell-frame toggles),parts_viewer(a paged grid, a focus turntable and a reassembled recipe ship with an explode toggle),widget_zoo(everynova_uiwidget factory, live and clickable in both skins),compare_asteroids/compare_planets(the number keys re-dress the focus subject) andasteroid_kinds/planet_types(the whole authored range of each, side by side). Last, the mission-scale pair:first_shift_mapis a fixed belt layout with a route over it - the most populated authored scene there is - andfirst_shift_shipsposes eight shipped hulls in a row. All of them still walk and capture underNOVA_AUTOPILOT, which is what keeps them on the probe gate.systems/- the correctness ranges. Every name carries a prefix for the KIND of check it is -system_functionality,bug_a regression range for a defect that was found,stress_load - andexamples/systems/README.mdowns the rule. The section curriculum first:system_attitude_hold(PD attitude),system_thrust_and_plume(burn -> thrust + plume shader),system_hull_damage(damage -> destroy -> ship survives, and the mass properties the losses move),system_section_severing(a destroyed interior section leaves a real hole and the structure behind it drifts free as its own wreck body, at the speed its own size asks for),system_destruction_finale(every destructible body - gltf section, procedural section, multi-part turret, asteroid - breaking into its OWN art rather than generic cubes, on one budget),system_turret_gunneryandsystem_torpedo_launch(the weapon ranges, the latter also the PN lead-a-crosser deep-dive), andsystem_blast_penetration(explosive falloff, one section shielding the next and two salvos that cannot use the hole they make in the same tick) andsystem_railgun_lance(the spinal gun: a commit that outlives the trigger, a charge cue, one slug raking six plates in a single step, and the recoil that shoves the firing ship). Beside them, the ranges that read a figure off the HULL rather than off a constant, each staged on a skiff and a capital at once:system_hull_scaling(the two reference hulls’ live inputs and what the game derives from them - lock range, the fire gate, the size a hull burns at),system_collision_damage(a ram at three closing speeds, and the docking speed that costs nothing),system_ai_combat(what an AI ship does when it fights) andsystem_ai_evade(what it does when a gun is held on it: three jink legs, each the size of the hull flying it). Then the cross-cutting systems, every fixture aScenarioConfigwritten in Rust and loaded withLoadScenario:system_scenario_grammar(the scenario language - variables, events, filters, actions),system_player_path(a scenario played through the real input pipeline: lock, kill, travel-lock, GOTO),system_cinematic(a SCENE: the prompt and title card it puts on the HUD, the pause that freezes the way out, the skip the player presses, and the two endings the scenario hears in order),system_outcomes(die -> the Defeat overlay -> Retry -> a clean reload -> kill -> the objective and the CHECKPOINT -> Continue -> the chained scenario),bug_outcome_pause(who owns the screen and whether the world is running behind them: with the focus pause opted in, alt-tab pauses a fight and coming back does not resume, an outcome landing over the pause menu takes the screen, and a timed advance taken while the player is away hands its pause to the pause menu without one unpaused frame),bug_refused_scenario(a chapter the content gate refuses ends the one it replaces, cursor freed and Main Menu working),bug_failed_assets(a broken optional mod is quarantined and reported, and a broken base asset is fatal and says so),bug_neutralized_quiet(a wreck’s point defence stands down) andsystem_borrowed_battery(the Flight Computer works idle player PDCs). Then the interface, driven by synthesized pointer input:system_ship_editor(build a ship and inspect it, refusals included),system_field_controls(the inspector’s number rows: the unit a field’s declaration gives it, the step a drag on its name moves it by, and the floor that drag ARRIVES at where a typed value is refused),system_input_modes(one owner of the keyboard at a time - the same key pressed under a text field, the gallery, a pending rebind and none of them, each verdict a NEGATIVE read after a settle because a key that went nowhere raises no event),system_ui_scale(a world-anchored label holds its LOGICAL offset when the scale factor doubles and the window changes shape, and the top bar and the stage’s nameplates are read at every shape alongside it),system_hud_indicators(where a screen-projected indicator lands),system_menu_boot(the shipped boot flow),bug_menu_fallback(a menu with no clean backdrop to draw ends the scenario it came from and stands on one bare camera) andsystem_scenario_picker(the Scenarios picker: its pane split must not depend on the selection - real fonts, real taffy, which a headless unit rig cannot measure at all - and the row a pointer selects and plays is the scenario that comes up once the atomic load gate releases),bug_sandbox_soak(the editor sandbox entered and then left alone, holding one physics step to its own timestep),bug_carve_apply(one cut that severs a rock, and what the main thread pays to SWAP the result in - the range counts grids rather than milliseconds, so it reads the same on every box),system_interface(the Tab interface and the;modal over it, driven with keys only: TAB, M and;must return through Escape to the same pane with the clocks frozen and no pane or flight action firing on the transition frames),system_command_shell(the modal over flight, the pause menu, the main menu and the interface, including the rebind gate that keeps;shut while a ship section waits for its key) andsystem_headless_map_goto(a click on a Map blip in window space, then GOTO, asserted on the ship’s autopilot with no GPU). Finally the STRESS ranges, one file each:stress_bullets,stress_torpedoes,stress_point_defense,stress_one_structureandstress_many_structureshold a thousand rounds, a thousand guided torpedoes, one battery working a stream of inbound ordnance, a thousand sections on one body and a hundred bodies, each asserting exact counts, a drain to zero and a teardown that leaves nothing, with a frame-time capture riding along. Their scale constants are named and carry the comment that they must NEVER reflect real content;stress_point_defensealso readsNOVA_STRESS_PD_MOUNTSandNOVA_STRESS_PD_BAYS, so one build can be swept across scales without moving what it asserts.stress_hull_collapseis the odd one out: it fires ONE siege slug down the long axis of a 1296-cell hull and asserts the corridor that left it and that every piece it shed went physical, then RECORDS the collapse frame cost and the debris peak instead of capturing frames, because a one-shot walk cannot fill the baseline window. The UI idiom: a beat NAMES its target (click_named/hover_named/ui_node_centre/ui_node_rectinnova_autopilot::input) so a layout move is survivable and only a rename breaks a run; nothing reaches a widget by triggering its observer or inserting its state component. A target past the fold is SCROLLED to (scroll_lines/scroll_pixelsturn the wheel), so a list taller than its box costs the run nothing, and a target behind a render target is AIMED at by undoing the composite that displays it (nova_os_window_px_showing), since a node laid out by an offscreen camera reports a rect in a space no cursor can be placed in. A driven run that still cannot reach a target says so and states its COVERAGE in the verdict -system_scenario_pickernames any row it gave up on and fails outright below two measurements, since its property is a comparison across selections. The simulation ranges deliberately do the opposite - their subject is the outcome chain, so pixel coordinates would only add layout coupling.screenshots/- the content producers, and the NAME says what a run makes:screenshot_*writes STILLS,loop_*writes VIDEO. One producer captures ONE thing in at most THREE frames, because a long scripted walk cannot hold the same result twice - the fat sets these replace strung a dozen captures onto one script, so a beat that drifted took every frame after it with it. Duplication between producers is the accepted price: several stage the same set, and the scene builders they share live inexamples/screenshots/shared/. The stills, by set: the Drydock drift beauty shot (screenshot_hero_ship), the menu and editor walks (screenshot_menu,screenshot_scenario_picker,screenshot_editor), the section closeups (screenshot_section_frame,screenshot_section_weapons), the Tab interface and the command shell (screenshot_interface,screenshot_command_shell), the Rock hollow combat beats (screenshot_radar_lock,screenshot_contextual_hud,screenshot_key_hints,screenshot_combat_lock,screenshot_combat_hud,screenshot_combat_wide,screenshot_hull_juice,screenshot_torpedo_run,screenshot_comms), the spinal lance (screenshot_railgun) and the flight computer around a real well (screenshot_orbit,screenshot_goto_burn,screenshot_flip_burn). Theloop_*producers write only video; recording is not their privilege, though - aloop_start/loop_endpair records from any harnessed example, soscreenshot_railgun,screenshot_editor,wfc_arena,system_torpedo_launch,railgun_wake_bench,stress_hull_collapseandsystem_lock_line_of_sighteach ship a loop beside their stills or their readings. What a run makes is decided byscripts/capture-web-media.sh(loops) andscripts/gen-web-screenshots.py(stills), which name the producer behind every file they package - with one deliberate gap. The three before/after splits are composed BY HAND from a capsule scene of the old release and a loop of the new one, so they enter the media script as IMPORTS: it names the split and the producer of its new half, but never runs that producer and never names thenews-0130-{sections,belt,death}-after.webmfile it writes.loop_sections_compare,loop_belt_compareandloop_death_compareare therefore run by hand, and the module doc on each says how. The posed LINEUPS live here too -screenshot_thruster_gallery(the shipped drive, the proposed shell family and the CC0 candidates in one named row) andscreenshot_damage_levels(the same ship at five damage levels, side by side, which is one comparison and so one producer). Neither registers a key, so a hand-run is a free-fly look at a still row; either would move toplayable/the day it growsgreeble_catalog’s selection layer.scripts/gen-web-screenshots.py --producersprints the list the site actually consumes, so a capture flow never runs off a hand-kept array. Every run of that script first checks the producer columns against the workspace’s[[example]]names and refuses to do anything if one no longer answers, andscripts/capture-web-shots.shstops on an empty list rather than packaging the shipped bytes back over themselves.
When adding a substantial feature, add or extend the range that drives it. When
fixing a bug, WRITE the range that reproduces it first: that is the doctrine in
AGENTS.md, and the invariant roster is what keeps it honest.
Every example is HARNESSED: it drives itself under
NOVA_AUTOPILOT=1, and probe can run any cataloged example -
cargo run --features debug probe run systems (or screenshots, or
playable) runs one category alone, and --all runs the whole catalog.
probe-full.yaml runs a curated 24-example set in five subject shards on a
release and on manual dispatch; a pull request runs a seven-example smoke
subset. Neither CI runtime suite runs the whole catalog.
Each example must reach Playing and exit without panic; every
systems/ range additionally carries panic-on-failure behavior assertions with
completion backstops (a stalled script fails instead of passing vacuously). The
rosters are pinned by the display-free
systems_ranges_assert_their_invariant_roster, so an invariant cannot be
deleted into a still-green run. The screenshots/ and playable/ examples
carry no behavior assertions of their own - they drive the shipped
scenes to capture frames - but every one walks an AutopilotPlugin step
timeline, so a beat that never resolves is an error exit naming that step, and
every one adds nova_probe::NovaProbePlugin, so a probe run grades the walk on
the engine invariants. Disk and catalog
cannot drift: the display-free catalog_matches_disk test
(crates/nova_probe_cli/tests/catalog_drift.rs) fails cargo test --workspace
when a new example misses its [[example]] block. That is the case nothing else
catches - with auto-discovery off, an uncataloged example file does not build at
all and no other tool says so.
The drivers themselves - AutopilotPlugin, the screenshot capture,
the completion protocol, and the full NOVA_* environment contract - live in
the nova_autopilot crate and are documented on
The automation harness. This page only shows the run
recipes; that page is the contract.
Harness runs are SILENT and fly on the DEFAULTS: any harness env
(NOVA_AUTOPILOT, NOVA_CAPTURE) zeroes the audio output via HarnessMute -
Xvfb hides the window but not the speakers, and nobody listens to a scripted run
- and makes
SettingsStorePlugininert, so the run loads nosettings.ron, saves none, and applies no window mode. Both read the same pair throughnova_gameplay::harness_env_active. The volume SETTING is untouched (the mute is an output mask; persistence and the settings menu never see it).NOVA_MUTE=0forces sound through a harness run;NOVA_MUTE=1mutes a normal one, and the game binary’s--muteflag does the same - see the outputs-off pair above.
A HAND-RUN example is not silent and not pinned: it reads the store like the
game does, so with no settings.ron saved it opens in the default window mode,
which is now borderless fullscreen. Save Windowed once from the game’s Graphics
tab (or window windowed in the command shell) and every example follows -
examples read that file, they never write it.
Examples as bug pins
A bug becomes a RANGE (AGENTS.md): reproduce it in examples/systems/
before the fix, and the fix is what turns the range green. A unit/App test
still pins a system-level mechanism, but anything that only manifests in a
composed scene belongs in a range (for example, system_menu_boot runs the
shipped boot flow with the ECS fallback error handler swapped to panic, so
unhandled command errors on those transitions fail CI). A range’s pin is an
autopilot-script assertion (a named step whose on_enter asserts, reached only
once the steps before it have waited on the world - see
system_hull_damage/system_hud_indicators for the style) carrying an
outcome: <slug>
marker on the roster; the release’s curated probe suite runs 24 selected
ranges, and a pull request runs seven of those. Other ranges compile in CI
but need an explicit probe run for runtime proof. Caveat: the handler swap
does NOT catch remove/despawn command warns (they bake in the WARN handler
at queue time).
Launching a scenario from the command line
--scenario <id> on the game binary boots straight into that scenario, past
the main menu - for anyone who would rather not click through the picker, and
for a mod author testing one scenario id in one command:
cargo run -- --scenario tutorial # a base scenario
cargo run -- --scenario my_mod_intro # anything an ENABLED mod registers
cargo run -- --scenario nope # refuses, and lists every id
- The id is matched against the MERGED registry (base plus every enabled mod), so a mod’s scenarios are launchable by id, and so are the menu backdrops - a superset of the rows the Scenarios picker shows. The editor’s own Play range is not in it: it is the open document, not installed content, and the only way to it is Play in the editor.
- An unknown id prints the full id list to stderr and exits non-zero. The check happens once the content merge has run, which is after the window opens: the merged registry does not exist before that.
- The launch is the picker’s own door (
NewGameScenario+GameMode::NewGameGameStates::Playing), so the scenario comes up through the same loader and the same non-blocking load screen as a click on Play.
- It is a launch REQUEST, spent on the first boot. Back to Main Menu re-reads
the content on the way out, and the second pass through
Loadedfinds no request left to open - so the flag opens its scenario once and then hands the session over to the menu, instead of being a door onto itself. - Native only. The wasm bundle has no command line.
Content CLI
content (nova_authoring::cli::main, crates/nova_authoring/src/cli.rs; a
subcommand of the game binary, not a separate bin) authors and validates the
game’s content. Two subcommands, run from the repo root:
cargo run content gen # regenerate the base and story-mod *.content.ron
cargo run content lint # lint the whole content tree
cargo run content lint --target <mod> # lint one mod (dir, id, or `base`)
cargo run content lint --target <mod> --report r.md # + write a per-mod report (md|html)
genserializes the code-built base content into the committedassets/base/**/*.content.ron. The base RON is GENERATED from Rust builders (nova_authoring::generation, backed by privatebase_content) - edit the builder and regenerate, never hand-edit the RON, or thecontent_ron_paritytest goes red.lintruns EVERY content check in one pass (theauditsubcommand was folded in here - balance is a kind of lint):- the identifier + geometry + resource checks the load/publish gates cannot
(dangling
NextScenariotargets, unspawnable filter targets, duplicate ids, scenarios with no terminalOutcome, resource-ref membership, …); - the generated-ship part check - each bundle’s section catalog, with base
and its transitive dependencies, must build one
nova_world_base::ShipPartSnapshot: positive finite part stats, a docking capture angle under 90 degrees, a section id overlaid only along a dependency chain, a usable hull, controller, thruster, cargo intake and docking port, a weapon or thruster that fires straight out of one of its own faces, and no socket on the face a weapon, thruster, cargo intake or docking port fires, opens or docks through. Each fault is reported once per owner: the bundle that authors it, or else each bundle whose catalog raises it; - the combat balance/fairness audit - every combat scenario’s derived sheet,
graded for spawned-dead (ERROR) and close-spawn (WARN) hostiles; a bundle
acknowledges its OWN deliberate imbalances in a
balance_acks.ronbeside its manifest, so the justification travels with the mod (a stale ack that matches no live finding is an ERROR, so every list stays pruned); - the flight-rig input-overlap check - a content
input_mappingsection bound to a key the always-on flight rig also binds (W/Space/RightTrigger burn, autopilot, …) silently double-drives flight and is flagged (WARN). --targetlints a single mod by directory or in-repo id (webmods/<id>,assets/mods/<id>, orbase);--report <path>writes a per-mod document (Markdown, or HTML for a.htmlpath /--format html) that names, for each finding, the file + element + explanation + suggested fix. Exits non-zero on any ERROR. Thecontent_lint_gate,balance_audit_gateandcontent_report_gatetests run these walks in CI.
- the identifier + geometry + resource checks the load/publish gates cannot
(dangling
Web build
WASM via Trunk (Trunk.toml, index.html):
trunk serve # serve the game alone on http://localhost:8080
trunk build --release
For the full site (game at /play/, mod portal at /mods/) with everything
watched, use scripts/serve-web.sh - see Local web preview
below.
.cargo/config.toml sets --cfg=web_sys_unstable_apis for wasm; bevy_rand
uses its wasm_js feature there. Trunk only supports the release profile.
The GitHub Pages deploy (.github/workflows/deploy-page.yaml) builds the
landing site (web/) at the root, the game under /play/, and the generated
mod portal (scripts/gen-portal.py) under /mods/.
The same sources fan out into three build targets that combine into one published site:
flowchart LR src[Sources] src -->|cargo| native[Native game] src -->|web build| landing[Landing + wiki] src -->|trunk| wasm[Bevy WASM game] landing --> pages[GitHub Pages] wasm --> pages pages --> root["/ (landing)"] pages --> play["/play/ (game)"]
Local web preview
The published site is three builds stitched together - the content site at /,
the WASM game at /play/, the generated mod portal at /mods/. Serving only
one of them locally is what makes Play fall back to the landing page and the
in-game Explore tab come up empty. Two scripts cover the two things you actually
want:
nix develop -c scripts/serve-web.sh # live dev: all three, watched
nix develop -c scripts/preview-web.sh # one-shot static build of the deploy
serve-web.sh starts all three servers and proxies the other two onto the
site’s origin, so a single URL has the deployed shape:
flowchart LR you([Browser]) you -->|":UI_PORT/"| site["webpack dev server<br/>watches web/src"] site -->|proxy /play| game["trunk serve<br/>watches crates, src, assets"] site -->|proxy /mods| mods["serve-mods.sh<br/>watches webmods/"]
Everything rebuilds on save: edit a wiki page and the tab reloads, edit a crate
and Trunk rebuilds the wasm, edit a mod and the portal is regenerated in place.
--release switches the game to an optimized build. Ctrl-C stops all three.
Each server takes a random free port in 7000-7999, so several worktrees can serve at once - the banner prints the URLs. Pin any of them, or point the site at servers you started yourself:
| Variable | Read by | Effect |
|---|---|---|
NOVA_UI_PORT | web/webpack.config.js | Fixes the site’s port. |
NOVA_GAME_PORT | scripts/serve-web.sh | Fixes the game’s port (exported as TRUNK_SERVE_PORT). |
NOVA_MODS_PORT | scripts/serve-mods.sh | Fixes the portal’s port. |
GAME_DEV_URL | web/webpack.config.js | Where /play is proxied. Default http://localhost:8080 (Trunk’s own default). |
MODS_DEV_URL | web/webpack.config.js | Where /mods is proxied. Default http://localhost:9000. |
Two things are worth knowing before you go off-script:
- Trunk needs explicit watch paths here. Its default (“the build target’s
parent folder”, i.e. the repo root) never fires in this repo, so a bare
trunk servekeeps serving the first build no matter what you edit.serve-web.shpasses--watchfor each real input (crates,src,assets,credits,build,index.html,Cargo.toml,Cargo.lock). - The portal must be same-origin with the game. The wasm build derives its
portal base from
window.location, so under/play/it fetches<origin>/mods. That is why the site server proxies/mods, and why a cross-origin?portal=override fails on CORS. See Publish a mod.
preview-web.sh is the other half: no dev servers and no proxies, just
trunk build + npm run build + gen-portal.py assembled into web/dist and
served statically on :8090. It does not watch anything, but it is the only
local check of the real deploy layout - run it before a release.
Regenerating the web screenshots
The site’s .figure blocks ship as placeholders; the real screenshots are
captured in-engine and packaged into web/src/assets/ by
scripts/gen-web-screenshots.py. Each figure auto-upgrades to its image at
runtime once the asset exists (progressive enhancement in web/src/site.ts), so
no HTML edit is needed - just drop the file in.
Capture and package in one command:
nix develop --command scripts/capture-web-shots.sh # every producer
nix develop --command scripts/capture-web-shots.sh screenshot_railgun # or a few
It stages into target/shots, runs each producer under Xvfb, and then packages
with scripts/gen-web-screenshots.py. It is the stills half of a pair:
scripts/capture-web-media.sh does the same for the webm loops, and
scripts/capture-lesson-media.sh the same again for the training handbook’s
demonstrations - the lesson_* producers, each writing one lesson’s still or
4x5 sprite sheet into assets/base/training/<lesson>.webp. That one ships
WEBP, not PNG: the Lessons pane draws a demonstration about 900 logical pixels
wide, so a sheet carries 960x540 cells (3840x2700 in all), and at that size a
lossless sheet is about 1.6 MB against about 150 KB encoded. Every lesson the
base handbook authors has a producer; one authored WITHOUT one keeps the
placeholder scripts/gen-lesson-media.py draws - lossless WebP, at the lower
resolution it is drawn at - and that generator REFUSES to redraw over captured
footage, so the two can be run in either order. Both point
NOVA_MODDING_CACHE_ROOT and NOVA_CONFIG_ROOT at the capture sandbox and stamp
a base-only mod set BEFORE EACH producer, so neither an installed mod nor a
producer that enables one on purpose (screenshot_scenario_picker does) can
dress the next shot.
The capture examples run headless under NOVA_AUTOPILOT: each is one autopilot
script whose steps pose the camera and shoot, and NOVA_CAPTURE is what makes
those shot steps write 1920x1080 PNGs rather than drive straight through. The Python step validates
each shot is 16:9, copies it in, builds the composite shots a single capture
cannot make (e.g. devlog5-radar-stance-slots, two lock stances side by side)
with a stdlib PNG codec, generates the section icons, and reports which shots
have no capture example yet. Commit the resulting PNGs (they are content, like
banner.png). Run python3 scripts/gen-web-screenshots.py --self-test to check
the PNG codec (decode/resize/compose) and the report’s classification rules in
isolation.
What is still missing
python3 scripts/gen-web-screenshots.py --report
Scans web/src/** for referenced assets/<name> images, diffs them against the
manifest and the shipped assets, and prints each gap with an owner class:
| Class | Meaning |
|---|---|
capturable | A game render a cataloged example can capture. |
manual | Authored art (post-card thumbnails, icons, diagrams) - no automation produces it. |
historical | A figure for an older shipped version; the current build can only approximate it. |
Wrong-shaped and unreadable assets, assets the site never references, and staged PNGs the manifest does not declare print the same way. The report is ADVISORY: it copies nothing and always exits 0, so it is a worklist (for the owner: what art to draw; for automation: what a producer could capture), never a gate.
It closes with the GAME’s half of the same worklist: every Scenarios-picker
thumbnail still on generated placeholder art, classed manual.
Scenario picker thumbnails
Every picker-visible scenario shows its own image in the details pane. Real per-scenario art is authored, not captured, so until it exists each scenario carries a deterministic placeholder - a 320x180 phosphor plate of its own title:
python3 scripts/gen-scenario-thumbnails.py # write every PNG
python3 scripts/gen-scenario-thumbnails.py --check # verify, write nothing
The PNG lands in the OWNING mod’s tree (assets/base/thumbnails/<id>.png,
webmods/<mod>/thumbnails/<id>.png) and is referenced as
self://thumbnails/<id>.png - never dep:// another mod’s art. Drop real art
at the same path and nothing else changes; the report stops listing it, because
the file no longer matches a fresh render. A new scenario adds one row to
SCENARIOS in the script and one entry to its bundle’s resources; a scenario
with no art of its own is what the coverage report lists.
Greeble meshes
The decorative fixtures a hull skin bolts onto its plates - vents, ribbing,
blisters, masts - are GENERATED, not modelled. scripts/gen-greebles.py builds
one .glb per JSON recipe in scripts/greeble-recipes/ into
assets/base/gltf/greebles/, all committed:
python3 scripts/gen-greebles.py # write every .glb
python3 scripts/gen-greebles.py --check # verify, write nothing
python3 scripts/gen-greebles.py --self-test # internal checks, no I/O
A recipe is a palette plus a list of primitives - box, cylinder, taper,
ribs, disc - each with a size, an at offset and an optional rotate.
Solids compose by overlap, not by CSG. Adding a piece is a new recipe file plus
one entry in assets/base/base.bundle.ron’s resources; no code changes.
Pieces are authored in the PLATE’s frame (the unit cell, out along +Y, y = 0
the mounting face) and the generator refuses anything behind that plane, wider
than half a cell, taller than its declared budget, or over 200 triangles. The
build is byte-deterministic, which is what --check gates: generated art that
churns turns every unrelated diff into a binary one.
The three mesh scripts (gen-greebles.py, cut-obj-into-hulls.py,
cut-obj-into-parts.py) share ONE hand-rolled, stdlib-only glTF writer,
scripts/nova_glb.py. Prove a change to it kept the committed art intact by
re-cutting a ship and diffing against webmods/the-ledger/gltf/parts/<ship>/,
which is byte-reproducible from its recipe.
Eyeballing the site
npm run ci proves the bundle compiles and the theme tokens are in sync; it
proves nothing about how a page LOOKS. For any styling, layout or readability
change, capture the pages and look at them:
nix develop -c scripts/shoot-web-pages.sh target/web-shots
It builds web/, serves web/dist on a free port and drives headless chromium
over the six page kinds (landing, news index, a news post, tutorial, wiki index,
a wiki page with code + a table + mermaid) at desktop and mobile widths,
writing <kind>-<width>.png plus a manifest.txt naming the commit. For a
before/after, run it once per commit into two dirs and compare the matching
pairs at identical crop and scale - a comparison at two different zooms shows
you the resize, not the change.
The theme is shared with the game
web/src/style.css and the base theme builders in
crates/nova_ui/src/theme/base.rs both mirror the :root block of
web/design/nova_ui_rework_poc.html - the interface palette and its control
vocabulary. That PoC is the single source: change it first, then both
consumers. The builders are content: content gen writes them to
assets/base/ui_themes/base.content.ron, so regenerate after a change.
The PoC ships two skins and the site wears only one. Everything the site draws
comes from the PHOSPHOR skin (the PoC’s body[data-skin="phosphor"] widget
zoo): flat translucent green fills, 1px phosphor hairlines, 2px corners on
controls, glow instead of bevel, and a solid --phosphor inversion for the
primary state. The light-3D HARDWARE vocabulary (--face, --rim,
--undercut, --well) stays in :root only to keep the mirror exact - it must
be consumed nowhere.
web/tests/theme.test.ts (part of npm test, and so of npm run ci) parses
the PoC and style.css and fails if the site’s tokens go missing or drift in
value, if the phosphor vocabulary stops being consumed, or if any hardware
material token is read outside :root.
Run verification (probe)
The run-harness is two crates split at the process boundary: nova_probe
(crates/nova_probe/) links into the example and collects the evidence, and
nova_probe_cli (crates/nova_probe_cli/) is the host side. Together they drive an autopilot
example, records what happened (correctness) and what it cost (performance),
and assembles one reviewable report. The POST-FEATURE CHECK - “did my change
break behavior or perf?” - is one command:
cargo run --features debug probe run system_player_path # clean + frame time + trace -> report
cargo run --features debug probe run system_player_path --correctness-only # clean behavioral evidence only
cargo run --features debug probe run system_player_path --samply # + named flamegraph
cargo run --features debug probe run system_player_path --baseline probe-runs # FPS deltas vs nearest prior commit
cargo run --features debug probe run system_player_path --repeat 5 # gated repeat set -> a usable worst frame
cargo run --features debug probe run system_player_path,system_scenario_grammar # comma list -> aggregate index
cargo run --features debug probe run systems # a whole category
cargo run --features debug probe run --all # the whole fleet
cargo run --features debug probe scenario tutorial # a SCENARIO by id, no example involved
cargo run --features debug probe scenario assets/base/scenarios/tutorial.content.ron # ... or by file
It runs the example headless (throwaway Xvfb; --display :0 to reuse yours -
and note that a software X server is not free, see
Measuring performance),
captures the run timeline + continuous invariants + the log into
probe-runs/<short-commit>/<example>/ by default (or
<out-base>/<short-commit>/<example>/ with --out <out-base>), optionally
adds the profiled and samply passes (separate builds - tracing overhead never
touches the clean numbers), and renders report.html + checks.json with a
provisional OK/WARN/FAIL/NO_DATA/UNPROBEABLE the reviewer confirms. Every
run dir carries a probe-run.json manifest (identity, full git SHA, passes, outcomes); probe report only re-renders dirs that have one. The commit root also gets
index.html, index.json, and probe-all.json, even when the spec names one
example. --correctness-only runs only the clean pass: timeline, invariants,
autopilot assertions, completion, reached-Playing, and log checks remain armed,
while frame-time and traced passes are omitted. CI uses this mode; release
verification uses the full run. Three verbs are the whole surface - run,
scenario and report - and each takes -h/--help, as does the root.
probe scenario takes those same passes to a scenario, with nothing in
between: the SHIPPED GAME BINARY is the program, launched with --scenario <id> or --scenario-file <path.ron> and carrying the probe collectors under
its debug feature. A positional ending in .ron is a loose content file,
registered for that run whether or not it is installed - so a scenario a
contributor or a mod author is still writing is measurable without adding it to
any catalog. Its self:// art resolves against the nearest enclosing
*.bundle.ron folder, the same place the merge would have resolved it. There
is no spec to expand and no aggregate: one subject, one run dir, the same
report.html + checks.json. Measuring a scenario needs NO example, no
[[example]] block and no Rust file - that is the point of the verb. Its
frame-time window waits for the scenario to be built (camera up, load gate
released), since the loader holds the clock through the build and a window
opened on a held clock is refused; see performance.
Every run spec resolves to a list. A single example is just a one-item list;
comma lists, category dir names, and --all expand against the [[example]]
catalog and run sequentially with continue-on-failure. The status index lives
above the example dirs: index.html (one row per example - verdict, measured
n/total, one column per check, duration, a link to its report), index.json
(the machine mirror), and probe-all.json (the re-render gate). The aggregate
verdict is the WORST row; the exit code mirrors it. --all runs the whole
catalog, and a bare probe run errors with the catalog listing rather than
starting a fleet sweep by accident.
Categories take single-digit minutes warm; --all is the pre-release/nightly
sweep (roughly half an hour). --baseline <base> searches <base> for the
nearest previous commit-hash directory in git history, ignoring compatibility
folders such as before, then each example compares against
<base>/<previous-short-commit>/<example>/frametime.csv when present. Without
--baseline, probe searches the same base used by --out, defaulting to
probe-runs.
Probe runs are profile-sandboxed: a run measures a commit, so it must not
depend on your desktop profile. Every native child run is pointed at an empty,
probe-owned profile under its own run dir - profile/mods
(NOVA_MODDING_CACHE_ROOT, the downloaded-mod cache and its installed.mods.ron),
profile/data (XDG_DATA_HOME) and profile/config (XDG_CONFIG_HOME, where
enabled_mods.ron and settings.ron live) - and the tree is wiped at the start
of each run. settings.ron is belt and braces there: every probe child run
carries NOVA_AUTOPILOT, which makes the settings store inert, so a run reads
no settings.ron at all and writes none. The sandbox is what isolates the mod
state. Without it, a mod cached in a structure an older commit cannot
parse, or a saved enabled-mod set, fails or shifts a run for reasons unrelated
to the code under measurement. Shipped content is untouched: assets/ and
assets/mods.catalog.ron load exactly as they do for a player, only YOUR saved
state is swapped out. To probe your real installed mods, export the variable
yourself - probe preserves any of the three it finds already set, and prints
which ones it left alone:
NOVA_MODDING_CACHE_ROOT=~/.local/share/nova-protocol cargo run --features debug probe run system_player_path
XDG_CACHE_HOME is deliberately NOT redirected (the shader cache lives there,
and throwing it away each run would make FPS numbers incomparable). The XDG
pair is how the dirs crate resolves on Linux, the supported probe host;
nova_probe_cli::native::profile_sandbox has the details.
Frame-time capture, the repeat set and its validity gate, the fixed-step and Xvfb traps, the frame-cost and census breakdowns, the preset sweep and the profiled pass all live on Measuring performance. This section stops at the run and its verdict.
Run timeline (correctness recording)
nova_probe also records WHAT HAPPENED during a run: set
NOVA_PROBE_TIMELINE=<out.jsonl> on any example that adds
nova_probe::nova_timeline() - which NovaProbePlugin does unconditionally,
so that is EVERY cataloged example - and the run appends one JSON object per
line: every GameStates/pause transition, every fired scenario
event with its payload (kills, area enter/exit, locks), every scenario-variable
change (old/new), plus the beats the autopilot script pushes itself via
nova_probe::probe_marker. Entries are flushed as written, so a panicked run
keeps everything up to the panic. Compare runs by ORDER and VALUES, not
timestamps (wall-clock and frame counts vary across hosts):
NOVA_PROBE_TIMELINE=/tmp/run.jsonl NOVA_AUTOPILOT=1 \
cargo run --example system_player_path --features debug
The timeline is native-only (no fs in the browser) and inert without the env var. It is the correctness half of the run-harness whose performance half is Measuring performance; the run report below renders both.
Continuous INVARIANTS ride the same stream: set NOVA_PROBE_INVARIANTS=1 (or
=strict to panic on the first violation) on a wired example and every frame
asserts what the engine guarantees - health within 0..=max and finite,
velocities finite and nothing more (manual flight is Newtonian, so no speed is
absurd), scenario Number variables finite, registered monotonic
variables never decreasing (opt-in per example: system_player_path registers
target_down/leg, system_scenario_grammar seven counters and latches,
system_cinematic eight scene latches, system_outcomes hostile_down), and a
total entity-count leak bound. A
monotonic is one-way within a SCENARIO LIFE, not for the process: the memory is
forgotten on ScenarioLoaded, so an example that replays through its loop
point re-seeds its latches without taking a false regression. Violations warn,
land on the timeline as kind: "invariant" entries, and feed the report’s
invariants held check.
The run report (one verdict surface)
run_report assembles a RUN DIRECTORY - whatever the passes above dropped
into it (timeline.jsonl, frametime.csv, trace.json, run.log, each
optional) - into a self-contained report.html plus a machine-readable
checks.json:
cargo run --features debug probe report <run-dir>... [--baseline <old-run-dir>]
Auto checks produce a provisional OK/WARN/FAIL/NO_DATA/UNPROBEABLE (process
exit from the run manifest, run completed, reached Playing, invariants held,
FPS vs baseline as a soft gate, log scan, artifacts loadable); a check whose
capability the example never declared is N/A - “not claimed” - and an
unresolvable one is SKIPPED - “not measured”; neither means “held”.
checks.json pairs the verdict with a measured: n/total figure plus
per-check structured data. A present-but-unloadable artifact degrades that one
artifact to absent and FAILS artifacts_loadable with the reason, rather than
aborting the report the failure would have been visible in.
Zero evidence is NO_DATA (nonzero exit) and a run that graded no declared
capability is UNPROBEABLE (zero exit - the sanctioned no-probe-plugin
opt-out, gated on its smoke checks alone), FPS improvements PASS (only regressions WARN -
frame numbers are host-noisy), a hung run is killed and still produces a
FAILing report, and the report ends with a reviewer checklist: the final
OK/NOT-OK is a human’s or an agent’s call, off checks.json without
parsing HTML.
Versioning and release
- Version:
workspace.package.versionin rootCargo.toml; crates inherit it. nova_info::APP_VERSIONcomes from theAPP_VERSIONenv var viabuild.rs. A--features debugbuild appends the short commit hash (0.12.0+9da8b055, or+unknownoutside a checkout), so the status bar names the revision. The build script tracks.git/HEADand its ref, so a new commit restamps it.- Packaging assets (icons, installer, .app) live under
build/.
The nix package
The flake packages the game as well as the development shell, so a player needs neither a checkout nor a toolchain:
nix run github:alexjercan/nova-protocol # build it and play
nix profile install github:alexjercan/nova-protocol
nix build .#default # ./result/bin/nova-protocol
Three outputs, and which one you want depends on what you are doing:
| output | what it is |
|---|---|
packages.default | the wrapped game - the one to install |
packages.nova-protocol | the same derivation, under its own name |
packages.nova-protocol-unwrapped | the bare cargo binary |
The unwrapped binary finds no assets and opens no window on its own. It is split out so that editing the desktop entry, the icon or the asset wiring costs a second instead of a fat-LTO relink of the whole Bevy graph. The wrapper is what makes it a game:
assets/andcredits/are installed undershare/nova-protocol, andBEVY_ASSET_ROOTpoints bevy’s reader at them. Without it the reader falls back to the directory the executable sits in, which in the store holds the binary and nothing else. It is set as a DEFAULT, so a modder can still point a packaged build at a working tree.- The libraries Bevy opens with
dlopen- vulkan, wayland, X11, xkbcommon, alsa, udev - go onLD_LIBRARY_PATH. The linker never records them, so a packaged binary finds none of them without this. It is the same list the development shell exports, shared inflake.nixrather than written twice.
The package builds with --profile dist, the profile
.github/workflows/release.yaml ships, so nix run gets the binary a player
gets. Fat LTO and one codegen unit: budget half an hour for a cold build.
webmods/ is NOT in the package. Those are the portal’s development fixtures,
served by scripts/serve-mods.sh; the mods:// source reads installed mods
out of the player’s data directory (~/.local/share/nova-protocol/mods), which
no store path can hold.
The package’s toolchain is rust-project.toolchain, set to the same
rustNightly value the development shell uses. Left alone, rust-flake resolves
its own from rust-toolchain.toml, and the package and the shell could drift
apart on a nix flake update - so there is still ONE pin in flake.nix, the
one the comment beside it names.
The desktop entry
nix profile install also installs
share/applications/nova-protocol.desktop and the icon into the hicolor
theme, so rofi -show drun, wofi, and the GTK and KDE menus list “Nova
Protocol” with its mark. ~/.nix-profile/share is already on XDG_DATA_DIRS,
so nothing else has to be wired; nix run and nix build do not install it,
because neither adds anything to a profile.
The icon is rendered from web/src/favicon.svg, the site’s brand mark, into
the eight hicolor sizes plus the scalable SVG. A launcher resolves
Icon=nova-protocol by name against the theme, so the sizes have to exist as
files: an icon theme with no scalable support finds nothing otherwise. The
art under build/ is still bevy_game_template’s placeholder bird and is
deliberately not used.
Cutting a release
RELEASE.md at the repository root is the controlling checklist; this section
only adds commands. Pushing a tag v[0-9]+.[0-9]+.[0-9]+* triggers
release-flow (.github/workflows/release.yaml). Steps, on master:
- Before the tag, prepare the approved News post (see “Writing the release
news post” below) and the docs and wiki pages the cycle changed (see
Keeping docs in sync). This book must build
clean (
mdbook build). - Bump
workspace.package.versionin rootCargo.tomland refreshCargo.lock:cargo metadata --format-version 1 >/dev/null. - Finish
CHANGELOG.mdasRELEASE.md“Finish the release notes” states. - Commit the version, changelog, News, and documentation changes together, staging each path explicitly.
- Confirm CI is green on that commit (
gh run list --branch master). git tag vX.Y.Zon that commit (CI reads the tag for the release name), thengit push origin master && git push origin vX.Y.Z.- Watch the run (
gh run watch) and confirm the four assets below are on the GitHub release. - Run
deploy-github-page(gh workflow run deploy-page.yaml), which keeps the published comic, then check the site and its download links. - With the itch.io page still Draft, run the approved
deploy-itchworkflow (see below) and check the four channels. The release is not public until the clean-platform checks pass and the page changes from Draft to Public.
The workflow uploads four assets to a release named after the tag: macOS
universal .dmg, Linux .tar.gz, Windows .zip, and a wasm-opt’d web zip.
The macOS job writes CFBundleShortVersionString and CFBundleVersion from the
tag, then refuses to create the DMG unless Info.plist declares the copied
nova-protocol executable. The workflow can also be re-run via
workflow_dispatch with a version input.
Deploying a release to itch.io
The manually dispatched deploy-itch workflow
(.github/workflows/deploy-itch.yaml) uploads an existing GitHub release to
alexjercan/nova-protocol. It never rebuilds the game. The job downloads the
four assets named above, and scripts/prepare-itch-upload.sh verifies and
extracts them into the stable Butler channels windows, osx, linux, and
html5. On macOS, hdiutil mounts the release DMG and ditto preserves the
app bundle metadata. On Linux, the Nix development shell supplies 7-Zip to
extract the HFS+ image; the script restores the declared app executable’s mode.
Windows and web ZIPs use ordinary unzip. The macOS preparation checks that
CFBundleExecutable names a real executable, and the web preparation requires
index.html and the wasm file at the upload root. Production CI stays on
macOS and uses the native path; the Linux fallback makes local preparation and
preflight available without Apple tools.
Configure the protected itch-production GitHub Environment before the first
run:
- secret
BUTLER_API_KEY: an itch.io API key for thealexjercanaccount; - variable
ITCH_TARGET:alexjercan/nova-protocol; - variable
BUTLER_VERSION: the exact Butler release to install; - variables
BUTLER_SHA256_DARWIN_AMD64andBUTLER_SHA256_DARWIN_ARM64: the SHA-256 sums of that release’s official macOS archives.
Use the Butler download documented by itch.io to choose the version, download
each macOS architecture archive from
https://broth.itch.zone/butler/darwin-<arch>/<version>/archive/default, and
record its shasum -a 256 output in the matching Environment variable. The
workflow refuses a floating Butler version or an archive whose checksum does
not match.
After the GitHub release and Pages deployment are complete, keep the itch.io page in Draft and run:
gh workflow run deploy-itch.yaml -f tag=vX.Y.Z
gh run watch
The protected Environment supplies the explicit approval boundary. The run
records release checksums and the prepared file inventory as an Actions
artifact. Confirm itch.io detected each platform, mark html5 playable in the
browser if needed, and launch all four channels on clean target systems. Only
then change the page from Draft to Public. A later Butler push updates a public
channel immediately, so a failed or mismatched channel blocks another deploy.
Writing the release news post
Every release cycle gets one News post on the site (/news/, markdown under
web/src/news/). News is the merged devlog + release notes: one post per
FEATURE release (v0.X.0). Patch releases do NOT get
their own post - they fold into the parent feature post’s ## Point releases
section (v0.5.0’s post covers v0.5.1 and v0.5.2). The terse per-version
list stays in CHANGELOG.md; source the post’s content from the cycle’s
CHANGELOG.md sections.
A News post follows the spirit of Factorio’s Friday Facts: a narrative lead,
then a handful of feature-by-feature ## sections written candidly (the
reasoning, the dead-ends, the piece you are proudest of), leaning on screenshots,
and - where a devlog video exists - an optional ## Watch the devlog companion
near the top (the written highlights must stand on their own; the video is an
extra). Do not just restate the terse CHANGELOG.md.
Adding a post touches three places and a rebuild check (mirror an existing post such as
web/src/news/0.5.0.md):
- Write the post at
web/src/news/<version>.md(e.g.0.6.0.md). The page shell (newsPostShellinweb/markdown.js) renders the H1, the<date> // v<version>meta line, and the footer (the Discussions prompt plus theCHANGELOG.mdpointer and “All news” link), so the markdown is just the body: the H1 (# vX.Y.0 - <title>), the lead, the##sections,.figureplaceholder blocks for screenshots to capture later, an optional.video-embedcompanion, a.callout.callout--breakingblock for any format break, and a closing## Point releasessection for the cycle’s patches. Do not add a footer or aCHANGELOG.mdlink yourself - the shell adds them. - Register it in
web/webpack.config.js: add an entry toNEWS_POSTS(newest-first) withslug/version/date/description. The plugin list and thehistoryApiFallbackrewrite both derive fromNEWS_POSTS, so no other wiring is needed. - Add a
.post-cardtoweb/src/news.htmlat the top of.post-grid(newest-first): a media thumbnail plus the date/version, title, and one-line excerpt. For the thumbnail, use the YouTube thumbnail (https://img.youtube.com/vi/<id>/hqdefault.jpg) if the release has a video, otherwise the.post-card__phplaceholder namingassets/thumb-news-<version>.png. - Rebuild and check it:
cd web && npm run ci(format check, lint, test, build).
Comic deployment
npm --prefix web run build:story builds only released comics into
web/dist-story/story/. Its local scripts, images, favicon, and font assets
stay under /story/. Shared theme/header sources are compiled into that
snapshot; unrelated website pages are neither built nor published. The shared
Google Fonts import remains external. Normal serving still uses the complete
website and labelled drafts; there is no second development server.
Pushing a comic-* tag starts deploy-comic. A manual run accepts an existing
comic tag for a retry or rollback. The workflow resolves the tag to a commit,
builds once, and passes the artifact and exact commit to its publisher. The tag
names a library snapshot, not a filter: publication metadata selects episodes.
Game vX.Y.Z tags and version numbers remain separate. The comic authoring
README owns the publishing procedure and first-rollout checklist.
The full Pages workflow uses build:site, which does not execute or emit comics.
Both publishers use scripts/deploy-pages.py. It fetches the latest gh-pages
under a shared github-pages-publish job lock, builds a temporary Git index and
working tree, and commits a scoped merge without changing the source checkout’s
index. A comic deployment replaces only story/; a site deployment preserves
it. Both preserve CNAME and ensure .nojekyll. Non-fast-forward pushes fail.
Pages hosting must use the gh-pages branch root. Both jobs explicitly request
and wait for a Pages API build with a write-scoped Pages token.
Bootstrap the new comic directory before a site-only deployment can remove old
root-level reader files. The guard requires story/release.json, which records
the comic tag and source commit. Use the current site’s workflow, not a workflow
from an older game tag that predates the ownership split. Serial publishing is
not a FIFO release queue; wait for one deployment before starting another.
npm --prefix web run test:deploy checks isolated builds with released/draft
fixtures and local bare-Git publishing with mocked HTTP. It never publishes to
GitHub. Normal npm run build still builds the complete released-only website
for checks; it is not the input to either scoped publisher.
Writing a story comic
web/src/comics/ is the single home for maintained comic sources. Each season
has comic.json; each episode has episode.json, an ordered episode.ts, one
TS file per page under pages/, and Python panel drawings under art/. The
directory names provide /story/season-1/episode-1/, with page ids in the
fragment. Shared faces, ships, and colors remain in scripts/nova_illustration/.
web/src/comics/README.md owns the DSL and build contracts. Public exporters
must not import task files.
/story/ lists seasons only, in reading order. Each season has one illustrated
row with its title, summary, selected episode count, and labelled local draft
count. Click it to open /story/<season>/, which lists that season’s episodes
in authored order. Each episode has one clickable row and one thumbnail;
drafts show how many pages are illustrated. There are no duplicate reading
shortcuts or repeated season cover above the episodes.
The season page returns to the library through Story. The reader returns to its episode list through the season title, such as Season 1; Pages opens its page chooser. The phosphor shell surrounds full-color art.
Run npm --prefix web run serve and open /story/ at the printed loopback URL.
Normal local development includes labelled drafts. Source edits regenerate pages
and reload the same page fragment. It uses web/.cache/story/development/site,
not web/dist, and does not serve stale files from a prior disk build.
scripts/serve-web.sh starts this same server with the game and portal.
npm --prefix web run build always selects released episodes, even with webpack
in development mode. Serving with --mode production also excludes drafts.
Selection happens before generation: an excluded episode’s code never runs,
and none of its routes, assets, dialogue, or script enters the output. Only
referenced assets are emitted; comic directories are not broad copy/import
inputs. With no released episodes, the public archive has a plain empty state.
Every metadata field is required. comic.json gives the unique positive season
sequence, title, summary, and ordered episode directory ids. episode.json
gives title, summary, cover scene id and alt text, TS entry filename, explicit
Python art-module paths, complete pageCount, and publication (draft or
released). Unknown fields, missing sources,
unregistered directories, duplicate ids, and invalid generated data fail.
Released episodes must form a prefix of reading order and have exactly their
authored page count. A season can contain released episodes and the next draft.
Publishing changes metadata after approval; sources stay in place. Comic release
remains independent of campaign implementation.
The TS page language owns action, dialogue, screen labels, narrative location
cards, and page layout. Dialogue uses stable ids; balloon placement is keyed
separately. Unillustrated pages remain TS scripts without fake art references.
The shared build engine derives reader definitions, transcripts, and a reading
copy named SCRIPT.md in the generated cache. That Markdown is not another
maintained source. Unillustrated story text never enters a reader manifest.
Python art modules register named Scene functions. The common builder exports
only referenced standalone panel SVGs; it neither draws nor strips dialogue.
Blank named text slots preserve labels’ painter order under hands and equipment.
Raw SVG is checked before export, and the browser checks it again on import.
comic-panels.ts composes the page frame, panels, labels, cards, and measured
balloons. comic-renderer.ts owns the DOM; ComicPlayer in story-reader.ts
owns fitted playback, contents, deep links, and input. New pages update
automatically. Route changes and build-engine JS changes need a server restart.
Open image exports the displayed full page. The compact Panels and Transcript toolbar buttons open scrollable modals without taking space from the page. Panels offers each raw art SVG and its lettered export. Close or Escape restores focus to the opening button. These exports share the reader’s measured layout. Automatic wrapping uses actual font metrics; explicit word-count breaks retain accepted lettering. The local Art only control hides speech. Automated browser checks inspect measured layout without a reader control or diagnostic overlay. Face checks use contour bounds, not hair boxes, and do not replace visual review. Full-color pages have no CRT tint, scanlines, or shade overlay. Older typed HUD helpers remain internal renderer building blocks, not a demonstration or a second story authoring workflow.
Run cd web && npm run ci, then inspect the season library, each season’s episode
list, and reader routes at desktop and narrow widths, including a project URL
prefix. Check browser Back and the labelled return links at both levels.
The reader displays one page. Buttons, contents, arrow/Page keys, wheel, and a
single-finger vertical swipe change pages. Modified zoom gestures are not page
turns. Open image uses the browser’s full-size image view and native zoom for the
complete composition. Transcript exposes the selected page’s derived text in
its modal; scrolling it does not turn the underlying page. Contents hidden from view are also hidden from keyboard
navigation. Mobile image fitting is an overview, not a claim that small
landscape lettering is readable without zoom or text.
Contributing a change
The everyday loop for landing a change:
-
Branch off
master. Work items are tracked astasks/markdown (see Task tracking below); check the backlog first. -
Build and format:
cargo check && cargo fmtbefore you commit. Do NOT runcargo testorcargo clippylocally unless asked - they are slow and CI is the source of truth; when you skip them, say so. -
Drive it with an example. For a substantial feature, add or extend the
examples/example that exercises it, with a harnessed autopilot assertion (see Examples) - this repo prefers a runnable example over an isolated unit test. -
Open a PR. CI (
.github/workflows/ci.yaml) runs on pull requests and pushes tomaster, so the integrated tree is revalidated after merge:cargo fmt --check,cargo clippy --workspace --all-targets --features debug -- -D warnings,cargo test --workspace --features debug, then a windowed seven-exampleprobe run --correctness-onlysmoke under Xvfb/lavapipe plus thenova_autopilotexample test under Xvfb. Three more jobs run in parallel with that one: a default-featurescargo check --workspace --all-targetsunderRUSTFLAGS=-D warnings, a wasm32cargo clippy --workspace --exclude nova_probe_cli(the host harness has no meaning in a browser), and a dependency-license gate. Those two exist to catch dead code and unused imports that only appear withdebugoff or on wasm - neither configuration is otherwise built. All of it must be green to merge.The curated release set lives in
.github/workflows/probe-full.yaml, a five-shard suite with no push or PR trigger.release.yamlcalls it and every platform build waits on it, so a tag cannot ship past a red selected example; run it by hand from any ref withworkflow_dispatch. Both runtime lists are named, soscripts/check-probe-suites.py(a step in thecheckjob) rejects unknown and duplicate names, empty sets, and smoke examples outside the full shards. It does not require every[[example]]to run in CI; the independent catalog drift test checks disk against Cargo.toml, and the default all-target check compiles the remaining examples.The wasm job is CLIPPY rather than
check, and it pointsCLIPPY_CONF_DIRatci/wasm-clippy/, whoseclippy.tomlbans the std APIs that COMPILE for wasm32 and then panic in the browser - a class of bug nocargo checkon that target can see. Those bans are correct only there: nativelybevy::platform::timere-exports std’s clock, so the same list would flag correct code in the main job. That is why the config lives outside the repository root, and why there is no rootclippy.toml.
House style is in AGENTS.md
at the repo root - Rust, Bevy, Nova, comments, documentation, changelog and web,
each a section. Commit messages are plain and use ASCII punctuation only.
Releases are a separate, tagged flow (see Cutting a release).
Task tracking
Work items live as markdown under tasks/ (managed with the tatr CLI), so
they are versioned alongside the code. Check the backlog before starting and
close tasks when done. Each task has its own folder holding its TASK.md plus
any task-scoped records (SPIKE.md, REVIEW.md, RETRO.md, NOTES.md).
Multi-task plans are tatr tasks too - a release plan is a task with the strand
breakdown in its body (or a release/meta tracker task linking the per-strand
tasks). docs/ is the source of this book, not scratch: transient working
files live outside the repo, and task-scoped records live in tasks/<id>/.
Project tour
Start here. New to the codebase? Read this book in this order:
- Project tour – this page: the crate map and where to change X.
- Architecture – the full crate graph, app assembly, state machines and frame flow.
- Building & running – toolchain, cargo commands, examples, the web build, and how to contribute a change.
- Then pick the guide for your change: Add a ship section or Extend the scenario engine.
This page only orients you: the crate map and where to change X. For exact symbols per concept, use the Concept index.
Nova Protocol is a 3D space game built on Bevy 0.20 with avian3d physics.
You build ships out of modular sections (hull, controller, thruster, turret,
torpedo bay), fly them with real Newtonian thrust and a diegetic GOTO/ORBIT/
STOP autopilot, work inverse-square gravity wells, and fight with deliberate
angular radar lock-on. On top of the game sits an event-driven scenario/modding
engine (RON data) and a web site + WASM build. It is a Cargo workspace: the root
nova-protocol crate is a thin shell; all the real code lives under crates/.
Crate map at a glance
Slugs are the workspace members. One line each – see Architecture for responsibilities and the dependency graph.
| Crate | Owns |
|---|---|
nova-protocol (root) | src/main.rs clap CLI + entrypoint; src/lib.rs re-exports nova_core. |
nova_core | Wiring only: AppBuilder assembles the whole plugin stack. No gameplay. |
nova_gameplay | The shared gameplay layer under the ship: integrity, damage, gravity, the SFX engine, juice, objectives, mesh/transform rigs, entity markers. Owns GameStates/PauseStates/GameMode. |
nova_ship | The ship and how it is flown: sections, input (player/ai/radar), flight and its autopilot verbs, the camera rigs, the PD controller, the ship’s soundtrack. |
nova_wfc | Generated hulls: a catalog plus a code-owned WfcPlan in, a ShipDesign out. Owns the plan types; every caller builds its own plan. No App, no systems - the editor’s Generate verb and the wfc_* examples call it. |
nova_hud | The flight HUD: one module per widget (crosshairs, target inset, ammo readout, objective markers, comms panel, keybind dock). Reads the ship, never drives it. |
nova_command | The command language and the NOVA COMMANDS terminal model: catalog, parser, prompt state. No bevy UI. |
nova_interface | The TAB interface (Map, Ship and Inventory panes) and the ; CRT command modal: pointer forwarding, casing, the back-out owner. A peer of the HUD, added by nova_core. |
nova_console | The command shell’s dispatcher: the executor behind the cmd> prompt and the channel’s command lane. Sits above nova_menu because a setting command writes the resources the settings screen owns. |
nova_scenario | Scenario engine: events, filters, actions, variables, world, loader, objects. |
nova_world | The streamed open world: a cell generated from its coordinate alone by a SectorGenerator type, checked, and a 5x5x5 active window streamed around one live scenario. Names no content; the game gets it through nova_world_base. Examples with their own game plugins install their own generator. |
nova_world_base | The base game’s open world: the NovaLayeredWorld generator and its cluster policy over three environment fields, and the plugin AppBuilder adds that streams the world only while an OpenWorld scenario runs with one player ship. |
nova_events | Shared game-event kinds + entity identity components (gameplay <-> scenario). |
nova_events_macros | The EventKind derive behind nova_events’ engine events. Its only consumer is nova_events. |
nova_assets | bevy_asset_loader setup; loads glb/textures/shaders/sounds; owns the mod merge, safe mode (a broken optional mod is disabled, not fatal, except during a scenario) + prefs. |
nova_modding | Bundle/content/catalog asset loaders and the Content routing enum. |
nova_mod_format | Pure serde types for the mod formats (engine-free); re-exported by nova_modding. The static mod portal is built by scripts/gen-portal.py. |
nova_editor | The Mod Editor (NovaEditorPlugin), shown in GameMode::Sandbox. |
nova_menu | Main menu + the ESC pause overlay; hands off to Playing. New Game opens the world seed modal first. |
nova_training | The training handbook’s data: the Lesson content type, catalog ordering, field notes, progress, and the pure lesson validation both content lint and the runtime merge run. A leaf under nova_modding, nova_assets and nova_menu. |
nova_input | The bindings registry: the one table of named actions and the sources each holds, the shared rebind capture, and the by-name dispatch. A leaf under every rig and every rebind surface. |
nova_ui | Shared theme, skin, themed widgets, screen composition, unit formatting. A leaf: every UI-drawing crate (nova_gameplay, nova_hud, nova_interface, nova_menu, nova_editor, nova_assets) draws from it. |
nova_debug | Debug-only plugin (inspector, overlays); compiled under the debug feature. |
nova_info | Exposes APP_VERSION, injected by build.rs. |
nova_autopilot | Scripted automation drivers + the run-completion protocol. Bevy-only, game-agnostic. |
nova_channel | Dev tooling: the process channel. Named inputs, pointer moves, text, keys and command lines in on stdin; world snapshots and acknowledgements out on stdout. |
nova_probe | Dev tool (not in the shipped game): the in-game half of the run-harness - the capability plugins an example wires (frame time, timeline, invariants, world snapshot, scene census, frame cost). |
nova_probe_cli | Dev tool: the host half - spawns runs, grades artifacts, renders reports; the probe run/report CLI. |
nova_bench | Dev tool: the agent bench - referees an external agent (a scripted baseline, pi, any process) playing a scenario over the process channel, audits and scores the run; the bench play/replay CLI. |
nova_perf_web | Dev tool: the wasm app probe run --platform web boots and measures. |
nova_authoring | Offline content pipeline: the Rust builders for built-in scenarios/sections, content -- gen (writes assets/base/**/*.content.ron), content -- lint. |
nova_meta_gen | Binary under tools/ (web-build tooling, not a game crate): writes default .meta sidecars for web assets (Trunk post_build hook). |
Want to change X? Start here
The highest-value table. Verified paths; follow the linked page for depth.
| I want to change… | Start in | Read |
|---|---|---|
| A ship section behavior | crates/nova_ship/src/sections/ | Ship sections, Add a ship section |
| Damage types / how a round travels | crates/nova_gameplay/src/damage.rs | Ship sections |
| Integrity (disable/destroy) | crates/nova_gameplay/src/integrity/ | Ship sections |
| How a body wears its damage | crates/nova_ship/src/sections/damage_*.rs + crates/nova_gameplay/src/integrity/{erosion,carve}.rs | Ship sections |
| How an asteroid carves, and what it costs | crates/nova_scenario/src/objects/asteroid_carve.rs | Scenario engine |
| Flight / autopilot verbs | crates/nova_ship/src/flight/ | – |
| Player input / AI | crates/nova_ship/src/input/{player,ai}/ | – |
| Radar targeting / lock-on | crates/nova_ship/src/input/targeting/ | – |
| Gravity wells | crates/nova_gameplay/src/gravity.rs | – |
| A unit, or what a field is measured in | crates/nova_events/src/units.rs + crates/nova_events/src/scale.rs | Architecture |
| The HUD (widgets) | crates/nova_hud/src/ | – |
The TAB interface, its panes, and the ; command modal | crates/nova_interface/src/ | – |
| The command catalog and parser | crates/nova_command/src/commands.rs | Architecture |
| A scenario event/filter/action | crates/nova_scenario/src/{events.rs,filters.rs,actions/} | Scenario engine, Extend the scenario engine |
| Scenario objects / loading | crates/nova_scenario/src/{objects/,loader/} | Scenario engine |
| What a streamed world cell holds, or when one comes and goes | crates/nova_world/src/{generation.rs,streaming.rs}, base generator, clusters and environment fields in crates/nova_world_base/src/{layered.rs,clusters.rs,environment.rs} | Architecture |
| Mod loading / merge | crates/nova_assets/ + crates/nova_modding/ | Mod files, Publish a mod |
| A built-in scenario or section | crates/nova_authoring/src/ (builders), then content -- gen | Create your first scenario |
| A generated hull, or the plan it is drawn from | crates/nova_wfc/ (plan.rs for the plan, WfcPlan::standard_hull for the shipped one) and each caller’s own plan builder | Concept index, Ship layout sense |
| The Mod Editor | crates/nova_editor/ | – |
| Shared UI theme / widgets | crates/nova_ui/ | – |
| The web site / wiki | web/ | Building & running |
The boot path in one glance
AppBuilder (in crates/nova_core/src/lib.rs) is the single place the app is
wired – DefaultPlugins + window/log/asset/render setup, then the plugin stack
(assets, gameplay, ship, scenario, HUD + interface, editor, menu, debug).
The state machines:
GameStates { Loading, MainMenu, Playing }– top-level lifecycle. LeavingPlayingreturns throughLoading: the content is re-read on the way to the front door, and the menu is built once, on the far side of it.PauseStates { Unpaused, Paused }– the ESC overlay, nested inPlaying.GameAssetsStates { Boot, Loading, Processing, Loaded, Failed }– the asset pipeline that gates entry; onOnEnter(Loaded)the app hands off toMainMenu/Playing. Only the base game is MANDATORY: an optional mod loads beside the pipeline, and a broken one is switched off and reported instead of failing the boot.Failedis the mandatory failure, and it is terminal - the loading animation is replaced by a report.
Gameplay systems run an explicit chain configured identically in Update and
FixedUpdate; avian3d physics runs on a fixed timestep in FixedPostUpdate.
The plugin order, exact sets, frame flow, and the Update-vs-FixedUpdate rule
live in Architecture – start there once the shape clicks.
flowchart LR
player["Player / AI input"] --> game["Game crates<br/>(nova_ship + nova_gameplay + nova_core)"]
game --> scenario["nova_scenario<br/>(events / filters / actions)"]
data["Data (RON)<br/>scenarios + mods"] --> assets["nova_assets + nova_modding"]
assets --> game
scenario --> game
game --> screen["Rendered frame + HUD"]
Keeping docs in sync
Nova Protocol documents itself across several surfaces, each aimed at a different reader. None of them updates itself, so a code change is not finished until the docs it invalidates are fixed in the same task. This page is the map: what the surfaces are, what to touch when you change code, and what to do when you cut a release. It is the overview; the detailed, command-level steps live in Building and running under “Versioning and release” and “Writing the release news post”.
The documentation surfaces
| Surface | Where | Audience | Kept in sync when |
|---|---|---|---|
CHANGELOG.md | repo root | everyone (terse, complete) | any user-visible change |
| News | web/src/news/*.md -> /news/ | players + modders | each feature release |
| Player wiki | web/src/wiki/*.md -> /wiki/ | players | player-facing behavior changes |
| Lore encyclopedia | web/src/lore/ -> /lore/ | readers + story authors | approved world facts and published story references change |
| Story library | web/src/comics/ -> /story/ | readers + story authors | navigation, TS page scripts, Python panel art, composition, or publication change; update the comic authoring README and keep drafts out of deployable builds |
| Creator docs | web/src/create/*.md -> /create/ | mod authors | a data format or the modding UX changes |
| Dev book (this book) | docs/ -> /dev/ | contributors | internals, architecture, or the dev workflow change |
| rustdoc | local: cargo doc --open | contributors | with the code (doc comments travel in the same diff) |
The tutorial is not a separate surface: it is the wiki’s Start-here landing
(web/src/wiki/getting-started.md -> /wiki/getting-started/), updated when
the first-flight flow changes.
CHANGELOG.md is the exhaustive machine reference (every version, terse,
grouped by subsystem). News is the story (one rich post per feature release).
The player wiki is the manual; /create/ is the modding manual and the
exhaustive construct catalog. This book is the developer’s map - how to run,
how to extend, where things live - and rustdoc is the API detail underneath
it. They overlap on purpose - the cost of that overlap is that one code change
can carry several doc obligations, which is what the map below makes explicit.
docs/ is the source of this book, not a scratchpad. Everything under it
is a maintained book chapter, listed in SUMMARY.md and built on every deploy.
Transient working files live outside the repo, and task-scoped records live in
tasks/<id>/.
When you change code
Before you commit, ask three questions and act on each “yes”:
- Did user-visible behavior change? Apply the Changelog section of
AGENTS.md; it owns the entry and release-baseline rules. - Did player-facing behavior change (controls, HUD, a verb, a section, a weapon, a scenario primitive, the modding UX)? Update the player wiki page(s) it affects, and the tutorial if the first-flight flow moved. A wiki page drifting behind the game is the exact failure to avoid.
- Did internals, architecture, or a data format change? Update the book
chapter(s) that describe them. A RON, bundle, catalog, or portal format
change must also land in the matching
/create/reference or publishing page in the same task, or every mod author reads a lie.
The dependency map
Which docs to check when you touch a given area. “Check” means read it and fix
it if the change made it wrong - not every change touches every listed page.
Player wiki names are web/src/wiki/ pages; /create/... names are the
creator pages on the site; linked names are chapters of this book.
| Code area (crate / dir) | Player wiki | Dev book / creator docs | Also |
|---|---|---|---|
The crate layout itself: a split, merge, rename, or move (crates/*) | Architecture (crate map + dependency graph + assembly), Project tour (crate map + change-X table), and THIS page’s own row keys | CHANGELOG (Internals) | |
The world’s scale and the quantity types: METERS_PER_UNIT, LOAD_LIMIT, the Meters / MetersPerSecond / Meters3 seam, or any authored field moving between a quantity type and a bare f32 (nova_events/src/units.rs, nova_events/src/scale.rs, nova_ui/src/units.rs) | every page that quotes a figure - glossary.md (the world unit is the build grid’s name and nothing else), combat-weapons.md, gravity-wells.md, sections/* | Architecture (the boundary itself - state it there, reference it elsewhere), Ship sections internals, Scenario engine, both Extending guides, and /create/ wherever a field’s unit is stated | web/src/widgets.ts (each widget either converts or holds SI - it cannot do both), CHANGELOG (breaking - what a field is measured in IS its format) |
Ship sections, integrity, typed damage, ammo (nova_ship/sections, nova_gameplay/integrity) | ships.md, sections.md (+ section children), hud.md | Ship sections internals, Add a ship section, /create/sections/ | CHANGELOG |
How a body WEARS its damage: the two readings and the authored looks (nova_gameplay/integrity/{erosion,carve,spew,chunk}, nova_ship/sections/damage_*) | ships.md, sections.md (+ section children), combat-weapons.md | Ship sections internals, /create/sections/ (damage_effects is a CONTENT format serialized into assets/base/sections/base.content.ron, so rule 3 applies), /create/base-content/ | CHANGELOG (breaking?) |
Asteroid carving: the signed field, its cost model, severing (nova_scenario/objects/asteroid*, nova_gameplay/mesh/field.rs) | combat-weapons.md, scenarios.md | Scenario engine (the mechanism AND what a remesh costs), /create/objects/ (the Asteroid fields, and that radius is durability) | CHANGELOG (breaking?) |
The derived skin, its plate vocabulary and skin styles (nova_ship/sections/shell_*, skin_*, scripts/gen-greebles.py) | sections.md | Ship sections internals (the derived skin), /create/styles/, /create/base-content/ (style ids, greeble assets), /create/objects/ (the ship’s skin / style fields) | CHANGELOG |
Flight: the manual burn, the autopilot legs and the telemetry they publish, controller, camera (nova_ship/flight, nova_ship/input, nova_ship/camera, the maneuver instruments in nova_hud) | flight-autopilot.md, keybinds.md, glossary.md (the Flight terms quote what each verb DOES), hud.md | Architecture, /create/actions/ (StopShip and the other helm orders - a completion contract that moves lands there), /create/events/ (the maneuver-completion events) | CHANGELOG |
Targeting, radar, weapons, turrets, torpedoes (nova_ship targeting/sections, nova_hud) | targeting-radar.md, combat-weapons.md, sections/turret.md, sections/torpedo-bay.md, sections/railgun.md, hud.md | Architecture | CHANGELOG |
Gravity wells, factions, world (nova_gameplay) | gravity-wells.md, factions.md | CHANGELOG | |
Scenario engine: events, filters, actions, variables, objects (nova_scenario, nova_events) | scenarios.md | Scenario engine, /create/author-a-scenario/, Extend the scenario engine, the creator reference (/create/scenarios/, /create/events/, /create/filters/, /create/actions/, /create/objects/, /create/expressions/ - a new/changed construct MUST land there, it is the exhaustive catalog) | CHANGELOG |
The streamed world: the cell generator, the active window, the streaming lifetime (nova_world) | Architecture (crate map + graph - state there whether AppBuilder still leaves the plugin opt-in), Project tour | CHANGELOG (Internals). No /create/ page: WorldConfig is code a caller writes, not authored content | |
Modding data format, bundles, catalog, local cache (nova_mod_format, nova_modding) | /create/mod-files/, /create/base-content/ (overlay rules, dep://base) | CHANGELOG (breaking?) | |
The ship content kind: what a hull IS vs what a spawn is (nova_scenario/objects/ship.rs, nova_authoring/base_content/ships) | sections.md | /create/ships/, /create/objects/ (the Spaceship spawn), /create/mod-files/, /create/reference/ | CHANGELOG (breaking?) |
Generated hulls: the collapse, the tiles, and the WfcPlan that draws them (nova_wfc, nova_editor/generate.rs) | ships.md | Concept index (the row under The ship), Ship layout sense | CHANGELOG. The plan is CODE, not content - a plan field is not a format break, and there is no creator page for it |
Mod portal + generator (scripts/gen-portal.py, nova_modding) | /create/publish-a-mod/ | CHANGELOG | |
The TAB interface and the ; command modal: the panes, the CRT emulator, the command catalog, and the dispatcher that runs it (nova_command, nova_interface, nova_console) | interface.md, commands.md, keybinds.md | Architecture (crate map, and the PauseStates named freeze) | CHANGELOG; a new command MUST land in commands.md - that page is the exhaustive catalog, and help is generated from the same registry, so the two disagreeing is a bug |
The process channel and its lanes (nova_channel) | Architecture (crate map), and the command lane’s vocabulary is commands.md, not a second wire reference | CHANGELOG (a lane rename is breaking for every run script) | |
Menus, editor, UI (nova_menu, nova_editor, nova_ui) | hud.md, sections.md, keybinds.md | /create/mod-editor/, /create/ui-themes/ (the UiTheme format), Add a ship section | tutorial, CHANGELOG; theme tokens: web/design/nova_ui_rework_poc.html is the source for BOTH nova_ui/src/theme/base.rs and web/src/style.css; the site draws the PHOSPHOR skin only |
The training handbook: lessons as content, the practice ranges, the progress record, the field notes (nova_training, nova_menu/training.rs, nova_menu/training_store.rs, nova_authoring/base_content/lessons.rs, nova_authoring/base_content/scenarios/drills.rs) | getting-started.md (the way in), and every page a lesson links to | /create/lessons/ (the Lesson item), /create/scenarios/ (the roles and the practice-range contract), Architecture (the crate map and the graph), Concept index | CHANGELOG; RENAMING A WIKI PAGE OR RETITLING A HEADING BREAKS A LESSON’S LINK - crates/nova_authoring/tests/lesson_wiki_links.rs resolves every authored wiki_path against web/src and fails until the lesson is repointed |
Automation drivers, the env contract, the completion protocol (nova_autopilot, nova_debug/harness.rs) | Automation harness | CHANGELOG (env rename? breaking for every run script) | |
ANY environment variable: a new one, a rename, a removal (NOVA_* anywhere in crates/ or src/) | Environment variables - the index of the whole set, and the rule for adding one | the roster in tests/env_contract.rs (it fails until you do), CHANGELOG (breaking for every run script) | |
The probe harness: capabilities, run grading, the report (nova_probe, nova_probe_cli, nova_perf_web) | Building and running (“Run verification”), Measuring performance, and the nova_probe rustdoc knob table | CHANGELOG (Internals) | |
What a frame COSTS, or how it is measured (nova_probe/capabilities/{frametime,framecost,census}, nova_core render setup, anything with a millisecond in its justification) | Measuring performance - and re-read the Xvfb section before quoting an absolute number anywhere | CHANGELOG (Performance) | |
App assembly, plugin order, states, the game binary’s own flags (nova_core, nova_assets, src/main.rs) | Architecture, Project tour, Building and running (“Launching a scenario from the command line”) | CHANGELOG | |
Content CLI: gen/lint subcommands, the base content builders (nova_authoring, the game binary’s content subcommand) | /create/author-a-scenario/, /create/sections/, Add a ship section, /create/publish-a-mod/, /create/mod-files/, Scenario engine, Ship sections internals, /create/base-content/ (the id/asset catalog - a builder change that adds, renames or rebalances an id lands there) | CHANGELOG | |
Packaging: the nix package, the desktop entry, the icon (flake.nix) | Building and running (“The nix package”, “The desktop entry”) | README.md quick start, CHANGELOG (Internals) | |
The website itself (web/) | Building and running, this page | ||
Comic/site deployment (deploy-comic.yaml, deploy-page.yaml, scripts/deploy-pages.py, webpack targets) | Building and running, web/src/comics/README.md publishing procedure | Preserve separate /story/ ownership, tag/commit provenance, and draft exclusion; keep the two publishers on one lock | |
Local dev servers (scripts/serve-web.sh, scripts/serve-mods.sh, scripts/preview-web.sh, web/webpack.config.js, Trunk.toml) | Building and running (“Local web preview”), /create/publish-a-mod/ (“Preview the repository portal”) | README.md quick start + scripts table |
“Check” means re-derive, not grep
A name-level sweep - “does the page mention the new crate?” - passes while every claim BETWEEN the names goes stale: a dependency graph drawing a crate with the wrong consumers, a crate map missing four members, a command attributed to the crate it moved out of. Someone then reads one of those and answers a question wrong, which is the failure the sweep was supposed to prevent.
So when a row above says “check”, it means: re-derive every crate name, module
path, command, symbol and dependency direction the page asserts, against the
current tree. A page that names the new thing can still describe the old
one. Grep the page’s own file:line and symbol claims and open each one - a
type that no longer exists is an unambiguous defect and needs no judgement.
Two structural traps this map cannot catch by itself:
- The map’s own row keys are crate/dir names, so a structural refactor invalidates the MAP too - that is what the first row is for.
- A lane-per-change epic has no lane whose job is the cross-cutting sweep.
Give the sweep its own step (or lane) whenever
crates/*changes shape. - A quoted FIGURE is a claim about a UNIT as well as about a number. Change what
a field is measured in and every page quoting it stays readable and becomes
wrong, while a name-level grep finds the pages that mention the field rather
than the ones that quote it. Re-derive the figure from the code; and check the
static fallback prose inside a
data-widgetblock, which no test renders.
When you cut a release
The full command-level checklist is in Building and running -> Versioning and release. At the doc level, a release means:
-
CHANGELOG.md: promote## [Unreleased]to## [<version>] - <YYYY-MM-DD>, leave a fresh empty[Unreleased], merge any duplicate subsystem headings that grew during the cycle, and update the compare links at the bottom. -
News: a feature release (
0.X.0) gets a new post atweb/src/news/<version>.md, registered inNEWS_POSTSinweb/webpack.config.jswith a card added toweb/src/news.html. A patch release (0.X.Y) gets no post of its own - fold it into its parent feature post’s## Point releasessection instead. Full steps: Writing the release news post. Post conventions the 0.7.0 post sets: structure the body as##sections with###subsections - the build derives the sticky TOC sidebar from those headings, so a flat post gets an empty TOC; and use the figure-placeholder format (a.figureblock that auto-upgrades to its screenshot oncescripts/gen-web-screenshots.pypackages the image) rather than inlining an<img>.Name every figure
news-<version>-<subject>and nothing else. A post argues about one release, so its media is frozen: both packagers refuse to overwrite anews-file that already exists (NOVA_UNFREEZE=news-0120re-opens the post being authored), and anews-name is a leaf that nothing else may source from. A figure named for a living asset is re-cut every cycle and silently reillustrates the post; a living page borrowing anews-figure is pinned to an old release and can never update.web/tests/assets.test.jsfails the build on either, so authoring a figure means adding an alias inscripts/gen-web-screenshots.py(stills) orscripts/capture-web-media.sh(loops). -
Wiki and creator docs: sync any player or creator pages the release’s changes touched (use the map above). Do this as you go during the cycle, not in a scramble at release.
-
This book: same rule - chapters stay current through the cycle, and
nix develop --command mdbook buildmust be green (a broken link is a build warning; treat warnings as failures). There is no release-time wipe step:docs/carries no transient content to wipe. -
Build check:
cd web && npm run ci(format check, lint, test, build) must be green; confirm/news/and the new post render, and the section TOC sidebar is populated.
Adding or renaming a page
- A chapter of this book: create
docs/<page>.mdand list it indocs/SUMMARY.md.mdbook buildwarns on a SUMMARY entry whose file is missing - treat that as a failure. - A site wiki, creator, or lore page: add it to the matching section in
web/src/docs-manifest.js, which drives publication, the sidebar, search, and related pages. Lore source and illustration conventions are inweb/src/lore/README.md; it is a website section, not a second mdBook. - A news post: edit
NEWS_POSTSinweb/webpack.config.jsplus a card inweb/src/news.html. - Retiring a URL: add a stub to
REDIRECTSinweb/webpack.config.js.
Verify any of the web/ changes with cd web && npm run ci.
Architecture
New to the codebase? Start with the Project tour for a faster orientation, then come back here for the detail.
Nova Protocol is a 3D space shooter built on Bevy 0.20 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 / Load / Lessons / Scenarios / Sandbox / Mods / Settings / Exit) and the ESC pause overlay. Buttons write GameMode and hand off to Playing. New Game first opens the two-step world setup modal (world_setup): a name and a seed drawn from the game’s entropy, then a start role picked from GameStartRoles; on the desktop build Create first makes the world’s folder under WorldsRoot, then inserts WorldSaveSession and OpenWorldSession and hands off. The Load screen (load_screen) lists every folder under WorldsRoot and resumes the one picked through the same handoff, showing a refusal reason on its row. Its Delete asks first, then calls delete_world, which removes only the files the game writes and refuses a locked, linked or foreign-file folder, and re-reads the list. save_status keeps a top-right readout of the open session’s last save, and leave holds Back to Main Menu, Exit and the window’s close button on a leave save, with an overlay offering Try again or Leave without saving on a failed write; the pause Retry in a saved world, and the Defeat the open world shows on the player’s death, instead load the last save from disk, writing nothing. After a death the session is spent: a way out writes nothing and waits only for a write in flight, and a spent session is dropped when Playing exits; a write still in flight then runs to its end and holds the world’s lock until it does. None of this runs on the web build or without a WorldSaveSession. The Settings modal (audio volume, graphics preset, UI theme, and the Controls tab that REBINDS every action in nova_input, one binding group at a time) is shared by both entry points. Persistence is NOT the menu’s: settings_store owns SettingsStorePlugin, which nova_core adds to every app, and it reads the same cross-platform store (RON file / localStorage), keybind overrides included. The two directions are split: every app READS, and only an app that builds the settings panel calls allow_settings_saves and earns the write direction. What the player has LEARNED is a second file beside it, training_store: opening a lesson reads it, and only a scenario Victory that a lesson’s proven_by names completes it. Same root, same access split, separate file - deleting settings.ron to fix your audio must not erase your progress. |
nova_editor | The Mod Editor: the Mod Hub, the scene, the block script and the asset benches (NovaEditorPlugin). Comes up on entering Playing, only in GameMode::Sandbox. |
nova_gameplay | The shared gameplay layer under the ship: integrity/ (health, the two damage readings erosion and carve, and the debris a carve leaves in spew/chunk), damage, gravity (gravity wells), markers (the entity markers the ship tags with and this layer reads), math, audio (the bus-and-route sound engine every voice in the game goes through: bus for the four routes and the three volume tracks, mixing for the distance rolloff and the cue throttle, spatial for the stereo placement, voice for the one playback path), juice, shake, settings (MasterVolume/GraphicsBudget with its GraphicsQuality presets + apply systems; the per-bus InterfaceVolume/WorldVolume/MusicVolume live in audio/bus), clock (the open world’s play-time WorldClock), 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, its autopilot verbs, the AI helm orders, and the RCS puffs a push throws in rcs_puff; the Dock helm order flies a free port of an AI hull onto a free station port as AutopilotAction::DockApproach, only along a corridor one hull shape cast found clear, and the order’s DockingConnectionRequest captures it; at an orbiting station the approach reads the station’s tidal pull, not the whole pull, routes leg A around each field body’s forecast path relative to the station (a close planet becomes the ring of its orbit) through searched corners, one or two, held in the station’s frame, and refuses a corridor such a path comes within clearance of; a hull that starts inside such a path flies out of it under the GOTO arrival law before it routes; leg A flies on the drive and the final close on the RCS, so the magazine lasts against the tidal pull, while a waiting hull and the last 2 u to the entry trim on the RCS; the corridor is cast again on each 1 s forecast refresh, and an approach with no clear corridor holds and casts again, refusing NoSafeDockPath after 300 s held in all; inside the corridor the hull holds the velocity of the station’s point under it, so a spinning station’s port is led by its mean spin over the last forecast refresh; one DOCK approach per station flies at a time, an armed one else the longest engaged, and the others hold the station’s velocity clear of every forecast path; nova_world_base station visits install the order), camera/ (the chase-camera controller and the chase/skybox/post/WASD rigs under it), physics/ (the PD attitude controller) and ship_audio/ (the soundtrack those five produce). Depends on nova_gameplay and never the reverse; NovaShipPlugin owns the SpaceshipSystems brackets and nova_core adds it after NovaGameplayPlugin. |
nova_wfc | Generated hulls: a function from a section catalog and a code-owned WfcPlan to a ShipDesign, with no App and no systems in it. Owns the plan types too (WfcGrid, WfcVacuum, WfcKeel, WfcPart), because which sections one generator may reach for is generator policy and not mod content. TileSet::build reads the plan against the catalog and builds tiles only for parts nova_ship::cell_grid_fit accepts, each read at the 24 cube rotations through nova_ship::oriented_part, hull() collapses one seed, and every hull is put through the game’s own content lint before it is handed back. Each caller builds its own plan from WfcPlan::standard_hull and its own UI rows: nova_editor’s Generate verb, and the wfc_* examples. |
nova_hud | The flight HUD: one module per widget (crosshairs, target inset, ammo readout, flight status, objective markers, the comms panel, the keybind dock, the screen-indicator projection they all share). Reads gameplay state and never drives it, so the dependency runs nova_hud -> nova_gameplay. nova_core adds NovaHudPlugin unconditionally, headless runs included - the widgets register their keybindings in build, so a gate took those bindings out of the registry. The crate places NovaHudSystems between the section and camera sets itself. |
nova_command | The game command language and the NOVA COMMANDS terminal model, with no UI in it: the terminal (terminal: prompt, scrollback, completion), the matcher and typo suggestions (shell), and the curated catalog, parser and CommandChannel (commands). |
nova_interface | The TAB interface and the ; modal. pane opens the full-screen interface over the frozen world with a map pane (schematic local space, contacts, GOTO) and a ship pane (schematic player ship, repair, rebind, mates); viewer is the orbit camera both run on. terminal is the retained CRT modal: the casing and shader, the terminal nodes, the keyboard and pointer systems, the one back-out owner for both surfaces, and the flight-log model. A PEER of the flight HUD, not one of its widgets: nova_core adds it, and nothing in nova_hud reaches into it (it reads NovaHudAssets and NovaHudSystems, so it sits ABOVE nova_hud). |
nova_console | The command shell’s dispatcher: the executor behind the CRT’s cmd> prompt and the channel’s command lane. nova_command owns the LANGUAGE (catalog metadata, parser, CommandChannel) and stays a leaf; this crate owns the half that touches the world - inspection, the flight log, the persisted settings, and the armed cheats. It sits ABOVE nova_menu on purpose: a graphics or volume command writes the very resources the settings screen owns, and one of them has to be downstream. |
nova_scenario | Scenario/modding engine: events, filters, actions, variables, world, loader, objects/, lint/ (the scenario half of the content -- lint checks), render_scale (the Low-preset resolution lever: scenario view into a reduced offscreen target, upscaled to the window). See Scenario engine. |
nova_world | The streamed open world’s mechanism. It names no content: the four ShipRoleType roles live in nova_gameplay’s market module, and CivilizationId (with its seeded name and per-aspect stream) in its civilization module beside StanceType and the player’s saved CivilizationStances, because pricing, the hire and sale rules and the save read them below this crate; nova_world_base’s stance module moves the table and writes each civilization’s effective stance as the Allegiance of its live bodies. The play-time WorldClock lives in nova_gameplay’s clock module for the same reason - the Jobs pane reads it for a job offer’s remaining time; this crate only advances it (NovaWorldPlugin’s Observe stage), resets it with the frozen ledger, and re-exports it. AppBuilder does not add it directly: the game gets NovaWorldPlugin<NovaLayeredWorld> through nova_world_base. Examples that bring their own game plugins install their own generator - the playable world_sectors, world_features and world_clusters, and the system_world_sectors correctness range; the two field pictures (world_field_slices, world_field_clouds) sample nova_world_base‘s environment fields directly, and world_civilizations paints its civilization field; none of the three streams anything. Nor does the playable world_sector_inspector: it prepares one cell at a time with prepare_sector and spawns it with materialize_sector, with no WorldObserver, handing it a bodiless observer point two cell edges past the cell’s centre on every axis, so no ship is ever held wherever the free camera stands. An explicit WorldConfig<G> (there is no Default - a seed, a cell edge and a generator are named or nothing streams) names the SectorGenerator TYPE the plugin is generic over; one app installs exactly one. The generator fills a cell from its coordinate alone and returns a BODY-ONLY SectorManifest - rocks, planetoids, ships and stations - and validate_manifest turns it into the private-field SectorDescription that preparation and materialization accept. Each record is its SectorObjectId, its placement and the whole scenario spawn config: an AsteroidConfig, a PlanetConfig, a SpaceshipConfig or a StationConfig that carries its whole StationDesign inline (validation refuses a Prototype source); a ship’s rotation, clearance, condition, civilization and role sit beside its config, and a station’s rotation, clearance and civilization beside its config. A station record is refused when its spawn names another design or shares its cluster stem with another station of the cell, and its hull and every hull it sells meet every inline ship design rule; a dead station sells none. The core enforces materialization contracts only: the requested cell, finite geometry, unique ids prefixed with the cell’s slug, shipped asteroid kinds, PlanetConfig::validate, a ship rotation that is a finite unit quaternion, a declared ship clearance that is positive and at most SECTOR_SHIP_CLEARANCE_MAX (400 m), an inline ship design with sections, unique section ids, prototype references only and finite section poses, every body’s clearance sphere wholly inside its cell, no two bodies overlapping, finite MetersPerSecond3 initial velocities for rocks and ships, a pinned silhouette seed on every rock, a rock lock-signature override that AsteroidConfig::validate_lock_signature accepts, a stated side on every ship (allegiance is Some), an inline design and never a catalog one, an asset path and never a runtime handle for a rock’s texture and destroy sound and for every design presentation sound, and a controller that is not a player and not an AI on a derelict; an AI route has finite waypoints, one stop per waypoint, each stop and the leash a finite positive number, and a finite engage delay. The canonical description prints a ship’s rotation sign-normalized, so q and -q describe one sector, and prints its civilization, role, design integrity, design presentation and each section’s prototype patch through Debug, so a non-finite value never prints as None. It sets no body count limit; density belongs to the generator. Its shared primitives are stateless: WorldGeometry::require_owning_edge takes the generator’s own placement inset, and bodies_clear takes the generator’s own margin (the core check passes zero). Where each object stands, its inset, spacing, retries and ids belong to the generator. The uniform baseline lives in the examples’ world fixture with its own inset, margin and body count, beside the clustered generator, whose asteroid-rich, rock-only, planet-heavy, derelict-only and low-rock groups are decided on a global lattice from three environment fields and owned body by body by the cell each centre falls in, so a group crosses cell faces. It lays out its hulls through nova_world_base’s plan_ship from the ship parts of the loaded catalog. streaming runs the lifetime in seven ordered NovaWorldSystems stages (Cleanup, Observe, Request, Collect, Materialize, Adopt, Retire) around ONE live scenario, clearing every cell and every frozen record when the scenario or the config changes, preparing cells off the frame and spawning them through nova_scenario’s own object factories. Before a cell’s root spawns, every ship’s inline design is resolved strictly against the loaded GameSections, and any resolver error panics with SectorFault::InvalidShipDesign, because the spawn’s own resolver skips a broken section. Every mobile rock and ship spawns with its manifest velocity in meters per second, converted at the engine boundary. Every ship spawns from its record’s spawn config, with its inventory in its hold and its credits as its balance, named <civilization> <role> or <civilization> derelict, former <role>. Inspection and the selected map contact show that name; a distant map blip shows only its minted contact code, and the map lists no contact before its code is minted. The crew policy is the generator’s: nova_world_base gives an intact ship an AI crew flying its civilization’s own allegiance (Enemy, Player or Neutral) with every system live, on a leash, an Enemy crew holding fire through its engage_delay grace, and gives a derelict no controller, the Neutral side and lootable: true. An AI crew flies the record’s waypoint loop, holding at each stop before the next leg, and chases no farther than its leash. Materialization adds DerelictShipMarker to a derelict, so its hull and docking ports stay live, every other section spawns inactive and detect_neutralized never neutralizes it; its lootable flag gives it LootableShipMarker, so a docked ship may Take its stock or its whole credit balance. A ship whose clearance sphere the WorldObserver’s body (its IntegrityEnvelope, or zero without one) overlaps is held as a PendingSectorShip child of the cell’s root and spawned unchanged by materialize_pending_bodies once the observer is clear; a station held the same way is a PendingSectorStation; retiring the cell freezes it with the rest. A generated station spawns through station_scenario_object under its cell’s root with its typed id, freezes as a Station record and thaws from it. Adopt moves every persistent body - rock, ship, canister, severed wreck, owed ore - under the root of the cell it stands in now, unless that root is retiring; the WorldObserver, the player’s fleet (PlayerFleetMarker) and authored objects stay top-level. A fleet escort travels with the observer, never freezes into a cell, and is saved in the player’s record; a neutralized escort leaves the fleet and its cell adopts the wreck. A body that leaves the window into a cell nobody holds live, and every persistent body of a retiring root, freezes into FrozenSectors: typed records from each owner crate’s freeze_* (mined field and owed ore, surviving sections with health, ammunition and plates, hold, credits, AI progress, canister id and contents). A body mid-sever, mid-collapse or mid-destruction stays live until it settles, and its retiring root stays live beside it; one unsettled for more than 600 advancing frames panics with its name, entity, cell and reason. Nothing frozen is simulated, and transients outside the window despawn. WorldClock counts the seconds of unpaused play while a world is armed: a body freezes with its reading, a thawed body carries ThawedAfter (the play time it stood frozen) for its owner’s catch-up rule, Cleanup resets it with the records and a save carries it. A revisited cell thaws its record and regenerates only its planetoids; a never-generated cell spawns its generated bodies beside the bodies that arrived in it. A plain hull with an id docked at a station with an id freezes inside the station’s record as a FrozenDockedShip with its ports, helm and joint frames, and a thaw docks it again through restore_docking_connection, not a new capture; a Load refuses a docked record whose ports its hulls lack or that shares a station port. Any other docked ship, a duplicate id or an unknown root child panics. Every root is scenario-SCOPED, so unloading the session takes the world with it, and no streamed entity is scenario-ADDRESSABLE. Invalid content refuses with a SectorFault instead of materializing a partial cell. |
nova_world_base | The base game’s open world over nova_world: the complete concrete NovaLayeredWorld generator and the stable ids the world and its open_world bootstrap share. The generator reads three independent global environment fields (material density, volatiles, human activity) and decides, at each node of a 50 km lattice jittered by up to 10 km, one asteroid-rich, rock-only, planet-heavy or derelict-field cluster or none, each reaching at most 8 km from its anchor. A planet-heavy cluster stands two to four worlds at the corners of a pair, a triangle or a tetrahedron, their bodies kept CLEARANCE_MARGIN apart while their wells may overlap; a derelict field holds rocks around its hulls. A cell replays every node whose cluster can reach it and owns each body whose centre it holds, so a cluster crosses faces and every cell agrees about it. A cell resolves every cluster’s planetoids, then its rocks, then its hulls, then its station, then that station’s traffic. The cell of a living station also plans two traffic hulls 2 to 4 km from it, after every other body, so no other body moves; a traffic hull belongs to its station’s civilization, needs no companion, is skipped like any hull and also past its cluster’s extent, and takes a patrol like any intact ship. A body whose clearance sphere crosses its cell’s face, or that comes within the 500 m CLEARANCE_MARGIN of a body placed before it in that cell, is skipped and counted; a planetoid that cannot be placed refuses the cell with SectorFault::Generation. A hull is placed only in a cell where its own cluster placed a rock or planetoid; otherwise the cell tries the escort rocks the cluster drew on the anchor side of that hull, places the first that fits and counts each one it tried, or skips the hull as having no companion. Escorts count toward each type’s derived extent, the halo and the gap between clusters, and a cell never looks past its faces for one. Overlap retries translate a placed escort and its hull together, keeping them 1,650-2,200 m apart; an impossible retry faults the sector. Every planetoid authors the mass that gives it a sphere of influence of 3.5 outer body radii under the default GravitySettings, and remains a static well. A cell that owns no cluster body may draw a background scatter of one to five rocks. The generator refuses an edge wider than 128 km or too narrow for its widest core of planetoids or a scatter, a world spacing or escort distance too short to clear the bodies it separates, bands that derive a cluster past its extent limit, and a planetoid reach whose surface pull passes the default surface gravity cap. sector_clusters and EnvironmentFields are public so debug examples read the same plan and fields; no streamed entity carries them. CivilizationField is the provisional civilization field that ship selection reads; the world_civilizations diagnostic reads it too. One civilization stands at each node of a 240 km lattice jittered by up to 40 km per axis. Its identity is the world seed plus the signed node, never its generated name. Its centroid, living or extinct status, advancement, role preference and allegiance (Enemy, Player or Neutral, one chance in three each, independent of the others) each come from their own seeded stream, so they do not depend on visit order. Influence is 1 at the centroid, 0.5 at 160 km and reaches zero only at 320 km, with no border between civilizations. The lattice bound keeps every place within reach of a centroid, and a place with none refuses with SectorFault::Generation. A place farther from the origin than a centroid gets up to four times its selection weight. Advancement is a smoothstep of the centroid’s distance from the origin, saturating at 4,000 km, times one plus a seeded offset of up to 0.1, so the first four 240 km rings read at most about 0.15 and field small hulls only. role_weights gives the four roles’ weights before content eligibility. ShipPartSnapshot is the provisional part catalog generated ships are built from. The open world builds one from LoadedSectionPacks when it arms and pins it in NovaLayeredWorld with the catalog digest, and every generated ship is laid out from it. plan_ship turns each hull a cell owns into a ship before the cell’s spatial checks: a primary civilization per cluster from those in reach of its anchor by selection weight, a 10% chance for each later hull to belong to another civilization in reach by influence, a role from the civilization’s role weights at the cluster’s environment among the roles its advancement can build, then generate_ship for a living civilization or generate_wreck for an extinct one. The planned ship carries the civilization’s id and the role to its manifest entry. An intact ship also gets a crew carrying its civilization’s allegiance; after the cell resolves bodies and relocates overlaps, plan_patrols gives each crew a loop of up to four seeded waypoints 1,500 m around the ship in its level plane, each kept only where the ship’s whole clearance fits the cell and clears every other placed body by CLEARANCE_MARGIN - fewer than two that fit leaves the ship holding its spawn point instead, counted in SectorClusters::routeless_ships. A derelict gets no crew. sector_stations places the generated stations: a living civilization keeps one station plus one per STATION_ADVANCEMENT_TIERS share of its advancement, an extinct one exactly one dead station, each at the hosting cluster nodes nearest its centroid within a 5x5x5 node block, memoized per civilization so the choice is a fact of the seed; a cluster holds one station, the nearer centroid winning a contested node, and a civilization that cannot place its count refuses the cell. Each civilization draws a station_archetype (ring, spire or yard, an even draw off its own stream) and StationClaims builds and caches one design for it: generate_station on the pinned snapshot at its advancement, seeded off its station_layout stream, then generated_station for services, storage, stock, credits and builds (every part the civilization knows), so its stations share one design. The stock holds at least one unit of each ammunition item a reloading weapon the civilization knows draws and no other ammunition; a stock heavier than the hull holds is StationLayoutConstraintType::StockOverHold, and the cell fails with SectorFault::Generation on station_stock. The layout’s clearance places it, the station spawns with that design inline (StationDesignSource::Inline), and a layout failure fails the cell with SectorFault::Generation. No catalog station is placed. A station orbits one lone world of its cluster at body radius plus margin plus clearance when the whole circle fits the owning cell, else stands at rest beside a rock of its cluster that stands whole in the same cell; a station that would be skipped refuses the cell. A dead station is the same design spawned derelict: true, Neutral. Every ship’s hold and credit balance come from ship_stock and ship_credits, each from its own stream keyed like the ship and scaled by the civilization’s advancement: a role-specific item mix, keeping an ammunition item only when a weapon the design mounts reloads from it (SectionKind::reload_item), toward a seeded share of what its hull sections hold at HULL_SECTION_CARGO_G each, a wreck’s toward roughly half that share of its remaining hull drawn apart from the intact ship’s, and a hold that cannot fit one unit of the role’s lightest item fails the cell; a wreck’s credits keep a seeded share of an intact balance at the same advancement, at least one credit. A retired cell comes back with its ships’ stock and credits as the session left them, and a saved Open World keeps it across a Load too, on the desktop build; the web build keeps no saved worlds. The generator gives mobile bodies a seeded tangent only for a unique well whose full orbit fits the clear, unfaded band in its owning sector; otherwise it gives an unsafe single-well start an outward escape, free space an explicit zero coast, or fails when an overlap has no safe relocation. An intact ship stands level on its nearest planetoid’s outward direction, or on +Y with none, turned by the cluster’s seeded yaw; a wreck takes a seeded uniform 3D orientation. Overlap relocation preserves a ship’s rotation, even if its nearest well changes. Every draw is keyed by the world seed, the cluster node and the hull’s slot, so a ship is the same from every cell. A planned hull reserves SECTOR_SHIP_CLEARANCE_MAX in the cluster’s extent and halo; the cell’s checks use the ship’s own root-centred clearance. A layout or content fault fails the cell: a sector fault panics streaming, so a ship that no layout fits crashes the world where it stands. The static content lint and the world_ships debug example build their own from the same repo bundle packs. It takes the base and mod section packs in any order, sorts packs and ids, and allows a section overlay only along a dependency chain, where the most dependent pack wins. It refuses non-finite or non-positive part stats, an advancement outside 0 to 1 or a non-positive draw weight, a docking capture angle outside 0 to 90 degrees, a socketed weapon or thruster that fires between two axes, a socketed weapon, thruster, cargo intake or docking port with a socket on the face it fires, opens or docks through, and a missing structural cube hull, controller, thruster, cargo intake or docking port, and reports every fault. It excludes parts without link points and parts that fail nova_ship::cell_grid_fit for any other reason, then reads each part’s authored advancement and draw_weight. Every civilization knows every part at advancement 0; of the parts above 0 its advancement allows it knows a seeded subset, each with chance 0.5, keyed to the civilization and the part id, with at least one eligible part per family always known, at least one structural cube hull and at least one turret for weapons. eligible groups a civilization’s known parts for a role by source so each pack weighs the same; civilian and industrial ships get no weapon, and only an industrial ship gets a mining beam. content_hash is a versioned FNV-1a 64 of the sorted effective catalog. generate_ship is the provisional shape-first ship layout. world_ships poses every role at its lowest eligible and full advancement with each drive layout beside four standard-plan WFC hulls, flies each intact ship, stands one generate_wreck derelict per role with every active section disabled in place, no controller and Neutral allegiance, checks that commands, a canister and a hit leave it inert but damageable, and reports the snapshot hash, each ship’s mass, thrust per cell and hull and weapon parts, and the missing weathered section art. It strips the generated skin so hulls compare bare; --skin or C restores it, and the harness also shoots the low and high rows clad. Clad WFC hulls name no style and wear the undressed derivation. It builds one ShipDesign from a snapshot, a role, an advancement and a hull seed: a mirror-symmetric spine of structural cubes from a one-cell nose to a stern that holds a bank of one drive (a centre column with mirrored pairs, or pairs only), a cross-section that changes along the length, flight computers in centreline cells inside the spine, each mounted on the hull surface instead where no flight computer part fits its cell, the role’s weapons, a cargo intake on the hull’s top or bottom, and docking ports: a mirrored pair out of the hull’s sides where one fits, else one port alone. It wears the derived skin in its role’s base style (role_style_id): civilian civilian, industrial industrial, scavenger salvage, armored armoured. Those ids are nova_world_base constants the style builders share. The spine mixes up to three structural cube types in rings, decks or a shell. It prefers one source per hull, except for scavengers. Civilian and industrial ships carry no weapon; scavengers get 1 to 4 weapon slots and armored ships 1 to 9, rising with advancement, each slot a centred weapon or a mirrored pair. An eligible industrial request draws 0, 1 or 2 mining beams instead, the chance and the pair threshold both rising with advancement; a drawn count with no legal mount fails the request rather than flying with fewer. Drives draw known parts within 0.25 below the ship’s advancement, capped at the highest advancement among the known parts, by draw weight and without replacement, before weaker parts of the same source; weapons and mining beams draw by weight with no floor, and each weapon slot then prefers a part not yet mounted. Every ship carries a cargo intake, so it can jettison and take cargo in: it opens up, else down, with structural cubes across its back. Every docking port opens to starboard or port with structural cubes across its back, and no intake reaches as far out as a port’s face (intake_in_dock_approach), where a berthed hull parks. A layout whose drives give less than 0.06 thrust per cell of collider mass at density 1, or that has fewer than one controller per 50 cells, is rebuilt with a larger bank and more controllers. A spine plan shrinks, narrower and flatter first, until its structural cubes leave 100 sections for fittings under MAX_SHIP_SECTIONS (2000), the per-ship section budget. Section ids name planned slots. Every layout is checked for unique ids, eligible snapshot parts, the role’s families, mated contacts, symmetry but for a lone docking port, backed intakes, backed side docking ports, no intake in a port’s approach, one socket graph (derive_link_point_graph), clear exit and docking lanes (blocked_exits), the thrust and controller floors, a clearance ceiling from 100 m at advancement 0 to 400 m at 1, and one off-centre outer structural cube outside every docking port’s back that a wreck could omit alone (Unruinable), so every generated ship can be ruined. After 8 failed seeded attempts it returns a ShipLayoutFailure with the seed, civilization, role and last constraint. generate_wreck ruins the same request’s intact design: it keeps the role, style, source, advancement, fittings and cell bounds, and omits seeded breaches of structural cubes, each grown inward from the outer hull, that keep one socket graph, clear lanes, the ceiling and the cubes across every docking port’s back. Each of 8 omission plans digs toward a fifth of the cubes (at least 3) off the mirror image, and the first to get there wins. Otherwise the plan with the most wins if it reaches the hull’s floor: as many mirror pairs of off-centre outer cubes as can each go alone, capped at that target and at what one deterministic pass in cell order omits together. Every generated hull has at least one. When all 8 plans fall short, that pass is the wreck; a pass that fails a wreck check fails the request, and nothing rerolls or skips it. No weathered or damaged part exists, so holes are its only ruin cue. NovaWorldBasePlugin, added by AppBuilder on the default game path after the scenario plugin, installs NovaWorldPlugin<NovaLayeredWorld> and one exclusive Update system ordered before NovaWorldSystems::Cleanup: it arms WorldConfig and puts WorldObserver on the player ship only while an OpenWorld scenario is live with exactly one player ship, and removes the config everywhere else. An armed world keeps its generator; two configs compare by catalog digest and snapshot hash only. A missing session, a second player ship, no LoadedSectionPacks or GameStyles, ship parts that do not build a snapshot, a role or station archetype style that no loaded style authors (ShipPartFault::MissingRoleStyle, MissingStationStyle), no station hull, controller, docking or weapon part open at advancement 0 (MissingStationPart), or a catalog digest that changes under an armed world panics, the last before any config is written. The same build and platform give the same pristine sectors from one seed in any visit order; what a player changes lasts the session, so a retired sector comes back as it was left. |
nova_events | Game event kinds and entity identity components, shared between gameplay and scenario. Also the world’s scale: the Meters/MetersPerSecond/MetersPerSecondSquared/Meters3/MetersPerSecond3 quantity types and METERS_PER_UNIT (units), and LOAD_LIMIT (scale). See Units and scale. |
nova_events_macros | Procedural macros behind nova_events’ derives. |
nova_assets | bevy_asset_loader setup. Loads glb/textures/shaders/sounds, and loads the base game’s own generated content (assets/base/) through the same bundle machinery as mods. Owns the mod merge (register_bundles, EnabledMods, ModCatalog; items.rs refuses item faults per pack and the merge inserts nova_gameplay’s GameItems) and LoadedSectionPacks (merged sections and versioned ContentCatalogDigest of effective content and mod metadata, independent of map/merge order; rejects non-finite values and excludes raw resource bytes), safe mode (safe_mode.rs: the optional half of the installed set loads outside the boot gate, and a broken mod is disabled, persisted off and reported; the merge runs lint_section_config on every section of every enabled bundle before it publishes, so an error in the base bundle inserts FatalAssetFailure and ends in GameAssetsStates::Failed, and an error in a mod quarantines that mod and every enabled mod that depends on it, except in GameStates::Playing, where an invalid mod, a failed mod load, or a dependent of a disabled mod fails the run the same way), the portal client and downloads (portal/), and prefs persistence. |
| nova_modding | Bundle/content/catalog ASSET LOADERS and the Content routing enum. See Mod files. |
| nova_mod_format | Pure serde types for the mod formats (bundle manifests, catalog declarations, the portal wire schema). Engine-free; re-exported by nova_modding. The static mod portal is built by scripts/gen-portal.py, not a crate. See Publish a mod. |
| nova_training | The training handbook’s DATA, a leaf under everything that teaches: the Lesson content type and its catalog ordering, the field notes the menu and both loading screens draw, the progress record, and the pure validate both content lint and the runtime merge run. It knows nothing about drawing a lesson or loading one - nova_modding routes Content::Lesson, nova_assets merges the catalog, nova_menu draws it, and nova_authoring builds the base set. Its only game dependency is nova_gameplay, for the AssetRef<Image> a lesson’s media resolves through. |
| nova_input | The bindings registry, a leaf crate under every rig and every rebind surface: the one table (InputBindings) that says which named actions exist, what each is called on screen, and which physical sources it holds, plus the shared capture (poll::InputSources) every rebind row reads and the by-name dispatch. Owners register their own defaults into it; nothing here knows what an action DOES. |
| nova_ui | Shared UI, a leaf crate everything that renders UI draws from: the UI THEME (theme::*: the authored UiThemeConfig format, the inheritance resolver, the two base themes the base mod ships as content, and the GameUiThemes/SelectedUiTheme/ActiveUiTheme registry every widget paints from), the themed widgets (widget: button, slider, segmented control, list rows, panel chrome), screen-level composition (screen: scrollable viewports and the list-beside-details layout the menu screens and the interface share), the flight-HUD chip language (hud), player-facing unit formatting (units), the shared typeface (font), the generic status_bar and the keyboard-ownership arbiter (input_mode: one app-global InputMode resolved from per-frame claims, with InputModeSystems as the ordering handle every keyboard consumer gates behind). Consumed by nova_gameplay, nova_hud, nova_interface, nova_menu, nova_editor and nova_assets. |
| nova_debug | Debug-only plugin (inspector, overlays). Compiled only under the debug feature. |
| nova_info | Exposes APP_VERSION, injected by build.rs. |
| nova_autopilot | Scripted automation drivers and the run-completion protocol the harness examples share. Engine-facing but game-agnostic; nova_debug, nova_probe and nova_probe_cli all build on it. See Automation harness. |
| nova_probe | Dev tooling (not in the shipped game): the IN-GAME half of the run-harness - the capability plugins an example wires to collect evidence about its own run (capabilities:: frametime, timeline, invariants, snapshot, census, framecost, all bundled by NovaProbePlugin), the contract an example declares, and the wire format the host reads. See Measuring performance and Building and running. |
| nova_channel | Dev tooling (not in the shipped game): the process channel a harness drives the running game through - named input holds, aim deltas, text, raw keys, pointer moves and command lines read as JSON lines on stdin, with world snapshots and per-line acknowledgements written back on stdout. It depends on nova_command for the command LANGUAGE, never on nova_console, so the wire cannot pull gameplay into a tool that only wanted to type. See Automation harness. |
| nova_probe_cli | Dev tooling: the HOST half of the run-harness - spawns autopilot runs as child processes, grades their artifacts (evaluation) and renders the reports (report). Owns the cargo run --features debug probe run/report CLI. The two halves meet at the filesystem: nothing in nova_probe reads a run’s output back. |
| nova_bench | Dev tooling (not in the shipped game): the agent bench. A referee between an external agent (a scripted baseline, pi, any process) and the game over the process channel; it owns the clock, audits every line and scores the run from the game’s own snapshots. The bench play/replay CLI. Depends on no nova_* crate. See The agent bench. |
| nova_perf_web | The wasm app probe run --platform web boots and measures: the real game started into a scenario with the frame-time capture armed. Dev tooling, never shipped. |
| nova_authoring | The OFFLINE half of the content pipeline (never shipped): the Rust builders that define every built-in scenario and section, the content -- gen serializer that writes them to the committed assets/base/**/*.content.ron, and the content -- lint walk that validates a content tree. |
| nova_meta_gen | Binary under tools/ (web-build tooling, not a game crate): writes default .meta sidecars for web assets that lack one (a Trunk post_build hook for AssetMetaCheck::Always). Boots a headless Bevy app, so it stays Rust. |
The dependency layering the table describes, from top-level shell down to leaf crates:
graph TD
root["nova-protocol (root)"] --> core["nova_core"]
core --> menu["nova_menu"]
core --> editor["nova_editor"]
core --> gameplay["nova_gameplay"]
core --> ship["nova_ship"]
core --> hud["nova_hud"]
core --> iface["nova_interface"]
core --> console["nova_console"]
core --> scenario["nova_scenario"]
core --> assets["nova_assets"]
ship --> gameplay
ship --> events["nova_events"]
hud --> ship
hud --> gameplay
hud --> ui["nova_ui"]
iface --> hud
iface --> ship
iface --> gameplay
iface --> cmd["nova_command"]
iface --> ui
menu --> iface
console --> menu
console --> cmd
console --> scenario
menu --> ui
editor --> ship
editor --> ui
editor --> wfc["nova_wfc"]
wfc --> ship
wfc --> scenario
gameplay --> events
gameplay --> ui
ship --> input["nova_input"]
hud --> input
iface --> input
menu --> input
editor --> input
scenario --> input
scenario --> events
scenario --> gameplay
scenario --> ship
scenario --> hud
assets --> modding["nova_modding"]
assets --> scenario
assets --> training["nova_training"]
modding --> modfmt["nova_mod_format"]
modding --> training
menu --> training
training --> gameplay
core --> worldbase["nova_world_base"]
menu --> worldbase
worldbase --> world["nova_world"]
worldbase --> scenario
worldbase --> ship
world --> scenario
world --> gameplay
world --> assets
world --> events
core --> debug["nova_debug"]
core --> info["nova_info"]
The graph is curated for readability, not exhaustive: nova_core and nova_menu
depend on nearly every crate below them, and edges implied by the layering (for
example nova_menu -> nova_ship) are pruned. The authoritative dependency list
for any crate is its Cargo.toml. Two edges people guess wrong:
nova_uiis not menu-only. Every crate that renders UI draws on it -nova_gameplay(which addsNovaUiPluginrender-gated),nova_hud(the chip language and screen-indicator styling),nova_interface,nova_menu,nova_editorandnova_assets. Abevy::ui_widgets::Buttonis heard and lit with no opt-in:nova_uirequiresHoveredon it and draws a hover and press ring on a plain one, andnova_gameplay::audio::ButtonCuePluginplays the press cue and hover tick.ButtonCueTypelives innova_uibecause that crate cannot reach the sound bank: absent means Select,Backis the back voice, andOwnedmarks a button whose handler or outcome plays the cue on every press that acts, withplay_menu_denyorEditorSays::refusefor a refusal. A selector stays silent on a press of its current choice.ButtonCueMuteis an owner’s live mute over that central voice: the NOVA OS chin controls write the monitor’s SND switch to it.nova_scenarioreaches up intonova_shipandnova_hud. It spawns ships and drives HUD-facing surfaces (comms dwell limits, target-inset render targets), so it sits beside them, not below them.
nova_world names no content, so its edges run downward only. The one game
crate above it is nova_world_base, which names base content and is what
nova_core adds; nova_authoring builds the bootstrap scenario and the
derelict hulls from the ids nova_world_base owns. An example that brings its
own game plugins gets no world unless it installs a NovaWorldPlugin itself.
The dev-tool crates hang off this graph without joining it: nova_debug builds
on nova_autopilot plus the game crates it inspects; nova_probe wires
nova_core + nova_autopilot into a measurable app; nova_probe_cli depends
only on nova_probe, nova_autopilot and nova_assets (it is a host process,
not a game plugin); nova_perf_web is nova_core + nova_probe; and
nova_authoring reads half the workspace to build and lint content offline.
Every crate exposes a pub mod prelude. Import from the prelude
(use nova_gameplay::prelude::*), not from inner modules. nova_core::prelude
re-exports all sub-crate preludes, so top-level code and examples usually just do
use nova_protocol::prelude::*.
Generic helpers live here too
The generic, non-Nova Bevy helpers (WASD/chase cameras, skybox, post-processing,
the mesh builder and the signed field, PD controller, health, status bar, the generic game-event queue
GameEventsPlugin/EventWorld) are nova’s own: the camera and transform rigs,
the mesh toolkit in nova_gameplay, the camera rigs and the PD controller in
nova_ship, the status bar and
tween in nova_ui, the event engine in nova_events, the inspector and
wireframe layers in nova_debug. They live in nova crates on purpose:
splitting a generic layer out before the game is done produces generic-looking
code shaped by one game’s needs. Whether any of it deserves extracting is a
question for after the game ships.
nova_ui::status_bar is the shape that line is drawn on. It is a generic
readout - a row of value/colour closures over any Any subject - and it stays
in the leaf crate. Nova’s own damage readout is NOT built on it: it is
diegetic, and every section fractures its own material as its DamageLevel
rises (nova_ship::sections::damage_cracks), from a vocabulary each section
AUTHORS (damage_effects). That readout keys on Nova’s section graph, its
authored vocabulary and an extended material, so it is game-specific and is not
a promotion candidate. A generic widget and a diegetic one are different things
at different layers, and the test is whether another game could take it as it
stands.
Boundary policy, from most game-agnostic to most game-specific:
- The generic-leaning modules named above - reusable Bevy primitives that happen to live in a nova crate; keep them free of game-specific types.
nova_gameplay- the shared gameplay layer, ship-agnostic.nova_ship- the ship above that layer.nova_hudandnova_interface- consumers of the ship and of gameplay, above both.nova_interfaceis abovenova_hudin turn: it orders itself againstNovaHudSystems.nova_core- wiring only.
Units and scale
Nova reasons in meters; the engine underneath does not. Authored metric
quantities, including vector velocities, and player-facing readouts use SI;
selected engine-side parameters such as gravity’s mu are exceptions below.
Bevy transforms and meshes, avian colliders and velocities, shaders and the
build grid all count in engine WORLD UNITS, and one world unit is
METERS_PER_UNIT = 10 m - so a build-grid cell is one world unit and 10 m on a
side.
The seam is the quantity types in crates/nova_events/src/units.rs - Meters,
MetersPerSecond, MetersPerSecondSquared, the Meters3 displacement, and
MetersPerSecond3 velocity, exported through that crate’s prelude. They carry
no Deref to f32, so an SI quantity cannot reach an engine API by accident,
and they cross in exactly two
directions: to_engine() on the way out to Bevy, avian, a mesh or a shader,
from_engine() on the way back. Every call site of either IS the boundary and
says so locally. AsteroidConfig::initial_velocity stays in meters per second
until its spawn path calls to_engine() for avian. Serialization is
#[serde(transparent)], so a content file
still reads blast_radius: 300.0 - the TYPE documents the unit, not a wrapper
in the file. LOAD_LIMIT (crates/nova_events/src/scale.rs) is the other half
of the world’s scale and is likewise SI, and nova_ui::units formats the
player-facing strings without converting anything, so a readout cannot pick up a
second factor of ten on its way to the screen.
Three kinds of number stay engine-side on purpose, and each says so where it is defined:
- Geometry the engine owns - a collider half extent, a signed-field cell, a
build-grid position, the answer
structural_armreads off posed colliders. These come out of avian or a mesh, so they are world units until something crosses them. - Quantities that are not a plain length. A gravitational parameter
muis L^3/T^2, so an SI one would be a THOUSAND times the world-unit one;DAMAGE_PER_UNIT_VOLUMEis hit points per cubic world unit, an INVERSE volume. Neither is “times ten”, and a silent conversion of one is a worse defect than leaving it engine-side - so they stay engine-side and quote the metric figure a reader actually wants beside them. - Dimensionless ratios - a fire-gate cone, a closing-speed multiplier, an angular acceleration in rad/s^2 - which the scale does not touch at all.
The rest of this book quotes a world unit only where one of those three applies, and says “world unit” out loud when it does.
App assembly
AppBuilder (in crates/nova_core/src/lib.rs) is the single place the app is wired:
#![allow(unused)]
fn main() {
AppBuilder::new() // Bevy DefaultPlugins + window/log/asset/render setup
.with_game_plugins(my_plugin) // optional: your own systems/observers
.build() // adds the plugin stack, returns App
}
AppBuilder::headless() is the same builder with no wgpu device, no window, no
winit event loop and none of the visual game plugins the render flag gates. Rendering is fixed by the
CONSTRUCTOR rather than by a setter, because DefaultPlugins bakes the wgpu and
window settings the moment the builder starts - a later setter could not reach
them. It is one switch and not two because the halves cannot be separated:
bevy_hanabi panics without a render sub-app, so dropping the device forces
dropping the plugins that need it.
build() inits GameStates + PauseStates, then adds, in order:
EnhancedInputPlugin, NovaInputPlugin (the bindings registry is a leaf: it
holds the table every rig is built from, so it lands before any plugin that
registers an action), GameAssetsPlugin, LoadingScreenPlugin,
NovaGameplayPlugin, NovaShipPlugin (the ship orders its sets inside
gameplay’s SpaceshipSystems brackets, so it comes after), NovaScenarioPlugin,
then NovaHudPlugin and InterfacePlugin (HUD first: the interface orders itself
against NovaHudSystems). Those two are NOT render-gated - a headless run
assembles both. Each registers its bindings inside build, and under a gate a
headless run kept only 15 of the 33 registry actions; everything GPU-side in
them is already guarded by bevy (UiMaterialPlugin and friends no-op without a
render sub-app). The price is that a headless measurement run carries HUD and
interface CPU systems. Then NovaEditorPlugin, then SettingsStorePlugin (EVERY
app, menu or not, and READING only: a settings panel is where a value is edited,
not what makes it apply, so an example that never builds a menu still flies on
the player’s own sensitivities, keybinds, volumes and quality preset - while an
app with nowhere to edit one cannot write that player’s file back. from_env
makes it inert under a scripted run, and the menu below is what grants the write
direction), then NovaMenuPlugin (the editor and the menu only
when no custom game plugins were supplied - the menu fronts the default app and
nothing else, so an example that brings its own game plugins goes straight
Loading -> Playing), then NovaConsolePlugin (the Command shell’s dispatcher,
unconditional and after the menu: a graphics or volume command writes the
very resources the settings screen owns, and an example with no menu still has a
CRT to answer status and ships on), and finally DebugPlugin under the
debug feature. On OnEnter(GameAssetsStates::Loaded) it hands off to
MainMenu (or straight to Playing when the menu is off) and spawns the
status UI. That handoff runs on
every pass through Loaded, boot and content restart alike, so what separates
them is the --scenario launch request: boot_into_the_game TAKES it, and a
restart therefore finds nothing to open and lands in the menu. The status bar is
taken down again on OnExit(Loaded) - its FPS icon is a handle out of the
collection the restart is about to re-read, and insert_status_bar_item hangs
every item off a Single root, so a second root would silence the new items
rather than draw a second bar.
NovaGameplayPlugin pulls in avian3d PhysicsPlugins (zero gravity, projectile
collision hooks) and ContactModelPlugin (speculative contacts on every body,
no swept CCD, no contact recycling; ram damage is tuned on that model),
bevy_rand, bevy_hanabi particles (on wasm via the WebGPU
backend), NovaUiPlugin (render-gated), the transform/lifetime/mesh rigs, and
the shared gameplay sub-plugins: integrity, damage, gravity, relations, audio,
juice, settings. The ship stack (input, sections, flight, camera, physics) is
NovaShipPlugin’s, and the HUD is NovaHudPlugin’s - both added by
nova_core, not by gameplay.
Gravity is one-way: planet roots are static wells, while mobile asteroids and
all ship roots are gravity-affected. Physical debris joins when its kinematic
collision grace ends. PlanetConfig::mass is required; asteroids have no
AsteroidConfig::mass. NovaLayeredWorld seeds a tangent only for a unique
well whose whole orbit fits its unfaded, collision-clear band and its owning
sector; inside an unsafe single-well band it seeds outward escape, and in free
space it seeds an explicit zero-velocity coast. A candidate inside overlapping
wells is repositioned deterministically within its cluster and owning cell,
first in a checked unique-well orbit band, then in well-free space. If no
candidate fits, SectorFault::Generation names the seed, sector and body;
streaming still panics on that fault. Seeded tangents do not guarantee lasting
orbits. A mobile body moves under the root of the cell it stands in. A
retired cell freezes its bodies with their position, velocity and damage, and a
revisit thaws them where they froze; a frozen body is not simulated, so its
orbit phase waits while its cell is off-window. Removing a well
clears its DominantWell references. If an ORBIT target disappears before the maneuver
reaches Hold, the autopilot disengages and the order fails; a completed held
ORBIT retires its execution and helm authority when its well disappears. A later
engage starts with fresh autopilot phase and plan state.
bevy_rand is added THERE and nowhere else, so the whole app holds exactly one
seeded GlobalRng (NOVA_SEED picks the seed, see
Environment variables). A gameplay system that needs
randomness takes it as Option<Single<&mut WyRand, With<GlobalRng>>> and builds
no fallback of its own: a second entropy plugin anywhere in the assembly does not
make two RNGs, it makes every one of those systems find none. Each of them logs
the broken contract once and then does its non-random work anyway - a destroyed
section still loses its collider, it just bursts no debris. nova_gameplay’s
plugin test pins the count at one.
States
GameStates { Loading, MainMenu, Playing }(nova_gameplay) - top-level lifecycle.MainMenuonly occurs whenNovaMenuPluginfronts the app (the default editor app); examples with custom game plugins go straightLoading -> Playing. TheGameModeresource (Sandboxdefault |NewGame) records what the menu handed off to.- Leaving
Playinggoes back throughLoading, not straight to the menu.nova_menuwritesReloadContentonOnExit(Playing)- the Wesnoth rule: the front door is where the game catches up with what is on disk, so a scenario just played, a ship just saved or a mod just installed is merged before the Scenarios picker, the campaign list or the ship catalog is read again.restart_through_loadingrewrites the pendingMainMenuintoLoadingin theStateTransitionschedule, beforeStateTransitionSystems::DependentTransitions, so the menu, its UI camera and its ambience backdrop are never built for the one frame the restart would have thrown them away in. Every exit path gets this - the pause overlay’s Back, the editor’s File menu, the outcome frame, a scenario’s ownExitToMenu- because the redirect is on the transition rather than on the four call sites, andOnExit(Playing)is also where the scenario is torn down (end_gameplay_scenario) rather than on whichever screen comes next. Menu ENTRY owes the same debt on its own account:load_menu_ambiencetriggersUnloadScenariobefore it draws anything, so the branch that finds no clean backdrop - a mod set with nothing flagged, or nothing the content gate lets through - puts its bare camera over an empty world instead of over whatever was last running. An app with no content pipeline (noGameAssets) is exempt: it has no restart to come back from. PauseStates { Unpaused, Paused, Interface, Commands }- the freeze axis.Pausedis the ESC pause overlay;Interfaceis the TAB pane (Map, Ship or Inventory,InterfacePaneType, a resource that outlives the state);Commandsis theNOVA COMMANDSCRT modal (cursor freed, no pause menu). The live transitions areUnpaused <-> Paused,Unpaused <-> Interface, andCommandsto and from each ofUnpaused,PausedandInterface: the modal is a surface OVER what was there, soNovaOsCloseTransition::return_torecords the state it covered and closing restores it.nova_gameplayowns the enum and gates the spaceship sets;nova_menuowns the toggles and the pause overlay UI. The two keys are gated differently. Tab needs a player ship and so runsin_state(Playing); Tab and M both refuse while a ship section rebind waits for its key, so the capture is not dropped.;is a GLOBAL gesture over the main menu, the editor and flight alike - gated only on no armed rebind (a settings chip or a ship section waiting for its key) and not mid-load, where there is nothing yet to inspect.Escapehas ONE owner for both surfaces (close_surface_from_menu_keys): an armed rebind keeps the key, then the modal closes, then the interface closes, then the pause menu may act, so one press never closes two layers. On the main menu the open shell is still the active modal, because its full-screen backdrop takes every pick and the menu buttons under it are unreachable until it closes.- The pause PANEL is reconciled, not spawned on the way in
(
reconcile_pause_overlay):Pausedis a freeze axis three surfaces share, and the outcome frame and the FAILED TO START report each hold it while drawing a modal of their own. The panel exists exactly whilePausedis the pause MENU’s, so an outcome landing behind it takes the screen, and an outcome clearing into a pause the player still owns hands it back. Exactly one modal, whichever arrived last. - Losing the window pauses interactive play when the player opts in
(
pause_on_focus_loss, Settings > Graphics > Pause when unfocused,FocusPauseSetting, default off so alt-tab keeps the run going): an unpaused run over a live scenario takes the ordinary pause the moment the window is not focused, and regaining focus never resumes - Resume is the player’s. A run already paused stays as it is, so an open interface, an open command modal or a shown outcome remains the active modal, and the editor’s build mode (no live scenario) never pauses at all.FocusPausecarries the policy, derived from the setting byapply_focus_pause_settingwhen the setting moves, and stays OFF under a harness (harness_env_active) whatever the setting says: a probe drives an X display nobody is looking at, and a--norenderrun has no window to lose. An authoredauto_advance_secskeeps running on the wall clock while unfocused, and the chapter it advances into gets no unpaused frame - the outcome’s pause is TRANSFERRED to the pause menu rather than released (examples/systems/bug_outcome_pause.rs). - The freeze itself is a NAMED hold, not a boolean:
ClockFreezecountsFreezeOwner::{PauseMenu, Interface, ScenarioLoad, WorldResume}and stopsTime<Virtual>+Time<Physics>while any owner holds,WorldResumeheld by a saved world’s Load from the moment it arms until every saved sector is live and its transients are back. It has to be named because the surfaces stack -;opens the modal over an already-paused game - and the handover is a state transition:OnExit(Paused)releases thePauseMenuhold beforeOnEnter(Commands)takes theInterfaceone, and the way back re-takes it. Naming the owners is what stops one surface’s release from unfreezing the other’s world.InterfaceandCommandsshare the oneInterfaceowner, andrelease_clocks_for_interfaceskips the release when the transition enters the other of the two, so opening the modal over a pane and closing it again never ticks the world between a release and the re-hold it would need. The hold is SCOPED toPlaying(hold_clocks_for_interface): what runs behind the main menu is the ambience backdrop, a cinematic the modal is drawn over rather than a game the player is being kept out of, and stopping it would leave the front door on a still frame for as long as the modal is up (examples/systems/system_command_shell.rs). - Every full-screen surface takes its
GlobalZIndexfrom one table,nova_ui::layer: HUD, the menu’s own panels, the editor’s ceiling, the report frames (outcome, FAILED TO START, MODS DISABLED), the pause menu, its Settings panel, the command modal’s backdrop and monitor, the diagnostics exempt from its dim, the scenario loading screen, the fatal asset report. The TAB interface root sits on the menu-panel layer: above the flight HUD, below the modal. Two modals sharing a number are ordered by whatever the UI stack’s traversal produces that frame, which is not a decision - the pause Settings panel and the open command modal shared 11 until the table was written, and only survived it because the pause overlay isDespawnOnExit(Paused)and is gone before the modal draws. The editor keeps its own ladder (nova_editor::ui::layer) for chrome the two tables cannot see across; that ladder lives entirely underEDITOR_CEILING_Z, pinned by a test, so a pause or an outcome covers editor chrome instead of losing to its foot bar. - The window a native run opens is BORDERLESS by default and has a floor.
WindowModeSettingdefaults toBorderless, and the store’s field carries#[serde(default)], so a fresh install and an older store with no field both open full-screen while a player who explicitly savedWindowedkeeps it;apply_window_modeis registered only for a READING store, so a harnessed run (inert store) never touches the window it was given.window_pluginhands the window managerMIN_WINDOW_WIDTHxMIN_WINDOW_HEIGHT(640x600) as resize constraints. The floor is not what makes the layouts safe: the Settings panel (the widest modal, and the same one from the menu and from pause) sizes itself atSETTINGS_PANEL_WIDTH_PCTof the window capped atSETTINGS_PANEL_MAX_W, because a canvas in a narrow page column obeys no native constraint. A fixed width there did not overflow - the panel is a flex ITEM, so at 500 px the 620 px panel measured 500, squeezed edge to edge with the keybind rows’ label column paying for it (examples/systems/system_pause_settings.rs). GameAssetsStates { Boot, Loading, Processing, Loaded, Failed }(nova_assets) - asset pipeline.Bootloads the UI font the loading screen itself draws with;Loadingloads the rest of the MANDATORY set (the shared art and the catalog, whose only loaded dependency is the base game’s bundle);Processingkicks the OPTIONAL cataloged mods, waits for them to settle, and merges. Scenario setup hooksOnEnter(GameAssetsStates::Loaded)- seeexamples/systems/system_scenario_grammar.rs.Failedis the MANDATORY failure and is terminal:nova_core’s loading screen replaces its animation with a report that offers Quit on native and browser instructions on the web, and draws in the default font when the boot font is what failed. An OPTIONAL mod never reaches it -safe_modedisables that mod, persists the choice, and the front door owes oneMODS DISABLEDreport per recovery episode (examples/systems/bug_failed_assets.rs).
The top-level lifecycle, the pause overlay nested inside Playing, and the asset
pipeline that gates entry:
stateDiagram-v2
state "GameStates" as GS {
[*] --> Loading
Loading --> MainMenu: menu app
Loading --> Playing: custom game plugins
MainMenu --> Playing: New Game Create / Sandbox
Playing --> Loading: leaving gameplay (content restart)
state "Playing" as Playing {
[*] --> Unpaused
Unpaused --> Paused: ESC / focus lost / outcome
Paused --> Unpaused: ESC
Unpaused --> Interface: Tab
Interface --> Unpaused: Tab / ESC
Unpaused --> Commands: :
Commands --> Unpaused: ESC
Paused --> Commands: :
Commands --> Paused: ESC
Interface --> Commands: :
Commands --> Interface: ESC
}
}
state "GameAssetsStates" as AS {
[*] --> Boot
Boot --> AsLoading: Loading
AsLoading --> Processing
Processing --> Loaded
Boot --> Failed: mandatory asset failure
AsLoading --> Failed: mandatory asset failure
}
AS --> GS: OnEnter(Loaded) hands off to MainMenu / Playing
Leaving Playing resets PauseStates back to Unpaused.
Frame flow
Gameplay systems run in an explicit chain, configured identically in Update and
FixedUpdate. nova_ship::NovaShipPlugin declares the brackets and the ship
sets; nova_hud slots NovaHudSystems into the gap itself:
SpaceshipSystems::First -> SpaceshipInputSystems -> SpaceshipSectionSystems
-> NovaHudSystems -> NovaCameraSystems -> SpaceshipSystems::Last
- Physics (avian3d) runs in
FixedPostUpdateon a fixed timestep. Rigid bodies getTransformInterpolationso rendering stays smooth between physics ticks. PostUpdatehosts the chase camera’s final move and the HUD’s world-to-screen projection, ordered after it.- While
Paused, the input and section sets are gated off and the clocks freeze. PreUpdatehostsnova_ui’sInputModeSystems, which resolves that frame’sClaimKeyboardmessages into the oneInputModeevery keyboard system then gates on. A claimant writes its claim.before(InputModeSystems); a consumer reads the resolved mode within_input_mode,in_input_mode_at_mostorowns_or_enters.
The render-rate chain (run in both Update and FixedUpdate) versus the
fixed-timestep physics step and the interpolation that smooths rendering between
ticks:
flowchart LR
subgraph render["Update / FixedUpdate chain"]
first["Spaceship First"] --> input["Spaceship Input"]
input --> section["Spaceship Section"]
section --> hud["Nova HUD"]
hud --> cam["Nova Camera"]
cam --> last["Spaceship Last"]
end
subgraph fixed["FixedPostUpdate"]
phys["avian3d physics (fixed step)"]
phys --> interp["TransformInterpolation"]
end
subgraph post["PostUpdate"]
chase["chase camera final move"] --> worldscreen["HUD world-to-screen"]
end
render --> fixed --> post
Update vs FixedUpdate - which schedule does my system go in?
The chain above is configured IDENTICALLY in Update and FixedUpdate
(nova_ship::NovaShipPlugin, two configure_sets calls with the same set order),
so a gameplay set can host systems in either schedule. The split is not
cosmetic: since every dynamic body opted into avian’s TransformInterpolation,
the game carries two pose representations on two clocks:
- Raw physics pose – avian
Position/Rotation, advanced on the 64 HzFixedUpdatetick. This is the truth the simulation integrates. - Render pose –
Transform, eased between the previous and current physics states, withGlobalTransformpropagated from it inPostUpdate.
Which schedule:
- Put a system in FixedUpdate when it feeds the physics sim – forces and
impulses, spawns whose motion the fixed clock advances (torpedoes, which
physics integrates; gun rounds, which
nova_gameplay::roundssweeps by hand after the physics step), guidance. It MUST read the rawPosition/Rotation(or compose the root’s raw pose with a local mount offset). DuringFixedUpdateof frame N,GlobalTransformstill holds the eased pose propagated in frame N-1’sPostUpdate, so it is stale render state here; the avian child-collider pose is one tick stale too. - Put a system in Update (or
PostUpdate) when it consumes the rendered frame – camera, HUD world-to-screen projection, effects. It reads the easedTransform/GlobalTransform, and every pose in one on-screen computation must come from the same frame. A consumer ofPostUpdate-written state must be ordered after its producer. - Put a system in FixedUpdate when what it computes DECIDES a fixed-step consequence, even when the same value is also drawn. This is the rule that is easiest to get wrong, because such a system looks like render-rate work: it has an on-screen output, so it reads as belonging beside the camera and the HUD. Ask instead whether anything on the fixed clock BRANCHES on what it writes. If it does, the state that branch samples advances once per FRAME while the branch is taken once per STEP, and the outcome is a function of the host’s frame rate.
Why gameplay is split across both: the chain runs in Update for
render-rate work and in FixedUpdate for sim-rate work; the same set order in
both keeps ordering consistent wherever a system lands.
The fixed loop runs on the SINGLE-THREADED executor (AppBuilder::assemble), so
a FixedUpdate system gets no parallelism from its neighbours - only from its
own par_iter. That is a deliberate trade for the schedules’ size; the number
behind it is in Measuring performance.
The same method caps the compute task pool at nova_core::MAX_COMPUTE_WORKERS
threads, so the parallelism a par_iter or a PostUpdate system can reach is
bounded too (why).
What breaks if a system lands in the wrong schedule – worked example: a
FixedUpdate system reading GlobalTransform. thruster_impulse_system used to
apply its impulse at the thruster child’s GlobalTransform, i.e. the previous
frame’s eased pose, up to ~2 ticks of ship motion behind the raw physics it was
pushing, while taking thrust DIRECTION from the raw Rotation – mixing both
clocks in one impulse. The application-point error is proportional to velocity;
at speed a COM-centered engine developed an uncompensated lever arm the throttle
balancer could not see, and the measured failure was a zero-true-torque lateral
engine spinning the hull to 7.1 rad/s in 15 frames (0 rad/s after reading the raw
pose). The fix (thruster_section.rs, see the comment above apply_linear_impulse_at_point)
composes both application point and thrust direction from the root’s raw
Position/Rotation. The same footgun produced the bullet-spew, HUD-jitter and
crosshair-twitch bugs in that family; all were error proportional to velocity,
which is why they only showed at high speed.
Worked example for the third rule – a DRAWN value that gates a fixed-step
branch. A turret’s aim chain solved the intercept, drove the hinges and wrote
the joint pose in PostUpdate, which is where a thing you can see belongs. But
shoot_spawn_projectile runs in FixedUpdate and asks, per tick, whether the
muzzle is inside a 0.92 deg cone of that aim point. The barrel’s pose was
therefore a staircase with one step per FRAME, its tracking residual scaled with
the frame period, and the cone is narrower than the residual’s span: measured on
stress_point_defense, the battery held its trigger 9.7% of the time at 20 fps
and 60.6% at 106 fps – same scene, same seed, a six-fold difference in how
strong point defence was. Moving the whole chain (solve, hinge controller, joint
sync) onto the fixed clock made the trigger duty 0.811-0.817 across the same
span. Nothing about the aim chain looked like sim work; the tell was that a
FixedUpdate system branched on its output.
Cross-system communication goes through events and observers (Bevy On<...>
observers, e.g. the integrity/destruction chain) rather than direct calls. Prefer
adding an event/observer over coupling two systems.
Assets
assets/ is runtime-only - everything the game actually loads: shaders/
(WESL, .wesl), icons/, sounds/ (UI chrome: menu clicks + objective chimes),
and the base//mods/ data (.ron). The base game’s own art and world audio
live UNDER assets/base/ (exported gltf/ models .glb, textures/,
sounds/ .wav world cues, banner.png), referenced by base content with
self:// and by mods with dep://base/<path>. It is the whole directory the
web (Trunk copy-dir) and native (release.yaml) builds ship, so non-runtime
files must not live here. The Blender SOURCES the gltf/ models are exported
from live OUT of the shipped tree, in top-level art/blender/, because nothing
loads them at runtime. The built-in sections, ships, styles, scenarios and training
lessons ARE data: the Rust builders under
crates/nova_authoring/src/base_content/ (sections/, ships/, styles.rs,
scenarios/, lessons.rs, assets.rs) are the single source, and
cargo run content gen serializes them to the committed
assets/base/**/*.content.ron the game loads like any other bundle.
Never hand-edit the generated files; edit the builders and re-run gen.
Find it in the code
- App assembly, plugin order:
AppBuilder-crates/nova_core/src/lib.rs; game binary and CLI flags -src/main.rs. - States:
GameStates,PauseStates,GameMode-crates/nova_gameplay/src/lib.rs; ESC overlay and clock freeze -crates/nova_menu/src/pause.rs. - The command modal and the TAB interface: the terminal model -
crates/nova_command/src/terminal/state.rs; the command catalog, parser andCommandChannel-crates/nova_command/src/commands.rs; the dispatcher that runs them against the world -crates/nova_console/src/dispatch.rs; the;gesture -open_command_shellincrates/nova_menu/src/pause.rs; the interface’s open, close and pane switch -crates/nova_interface/src/pane.rs; the shared back-out owner -crates/nova_interface/src/terminal/input.rs. - Frame-flow sets:
SpaceshipSystems-crates/nova_gameplay/src/plugin.rs; chained inUpdate+FixedUpdatebyNovaShipPlugin-crates/nova_ship/src/lib.rs. - Who owns the keyboard:
InputMode,ClaimKeyboardandInputModeSystems-crates/nova_ui/src/input_mode.rs; the editor’s claimant -declare_editor_keyboard_ownerincrates/nova_editor/src/lib.rs. - The two damage readings:
DamageLevel-crates/nova_gameplay/src/integrity/erosion.rs;DamageMarksand the carve cost model -crates/nova_gameplay/src/integrity/carve.rs. - Asset gate and mod merge:
GameAssetsPlugin-crates/nova_assets/src/plugin.rs;register_bundles-crates/nova_assets/src/merge.rs. - The training handbook: the lesson format and catalog ordering -
crates/nova_training/src/catalog.rs; the validation bothcontent lintand the merge run -crates/nova_training/src/validate.rs; the screen -crates/nova_menu/src/training.rs; the progress record and what a won scenario proves -crates/nova_menu/src/training_store.rs; the base lessons, their practice ranges and what each one isproven_by-crates/nova_authoring/src/base_content/lessons.rsand.../scenarios/drills.rs. A lesson’swiki_pathpoints into the web manual underweb/src/, andcrates/nova_authoring/tests/lesson_wiki_links.rsfails if a page or heading it names stops existing. - API detail:
cargo doc --open -p nova_core(any crate from the map works).
Spaceship sections and integrity
To add a new section kind, follow the guide Add a ship section.
Ships are assembled from modular sections. Each section is a child entity of the ship root with its own collider, mass, and health, and contributes one behavior (structure, thrust, steering, guns). The integrity system tracks how sections connect and handles damage, disabling, and cascading destruction.
Sections (nova_ship::sections)
A section is a SectionConfig { base: BaseSectionConfig, kind: SectionKind }.
BaseSectionConfig is shared by all kinds: id, name, description,
health, a required build recipe, optional destroy_sound, optional
collider, structural link_points, hide_in_editor, and damage_effects -
the authored list of looks this section wears as it is damaged (see Damage is
two readings).
build is a BuildRecipe (nova_gameplay::inventory): credits for the
work, parts as a count per Parts item id, and strip_yield in (0, 1].
It has no serde default, so a section without one fails to load.
lint_section_recipe (nova_scenario/src/lint/ship.rs) refuses an unknown or
non-Parts item, a zero count, zero credits, a yield outside (0, 1], and a
recipe whose yielded parts at bid are worth as much as the recipe at ask. The
merge lint runs it on every catalog section; the authoring walk and
check_design_sections run it on bundle and inline sections. Base rows live
in crates/nova_authoring/src/base_content/sections/recipes.rs.
A destroyed section drops BuildRecipe::strip_parts, floor(strip_yield * count) of each part, as cargo canisters; a destroyed ship root drops its
hold, and a destroyed station root its StationStorage with it. A section destroyed in its root’s structural collapse adds its parts to
the root’s drop, so a ship destroyed whole leaves one drop. CargoCanister::pack packs one drop’s stacks together into mixed canisters
of whole items, filled in item id order up to CARGO_CANISTER_MAX_MASS_G
(1,000 kg); separate drops never share a canister. The drop is a
MinedOreDrop that the mining ejector drains, so it freezes, thaws and saves
like mined ore (nova_scenario/src/wreck_cargo.rs).
Lost sections and the rebuild
The section spawn of an authored ship inserts ShipDesignSections
(design_sections.rs) on the root: every design section with its pose and
patched config, live or not. A lost section is a design section with no live
child of that id. Section codes (PDC-1) derive from the design by
ShipDesignSections::class_index, so a code holds while a sibling is lost and
after a rebuild; a root with no design falls back to the live order.
remember_lost_section_binding copies a lost section’s live input binding
into the root’s input_mapping before it despawns, so a rebind the player made
survives the loss and a save.
A thruster with no SpaceshipThrusterInputBinding fires on the Main Drive:
manual_burn_system (nova_ship/src/flight/manual.rs) balances only thrusters
without one. The Ship pane’s Control line names the main_drive keys from
InputBindings (ShipSectionView::binding_text). Rebind gives the thruster
its own key or pad button, which fires it alone and takes it off the balance;
reserved flight sources, keys and pad buttons alike, are refused. Backspace
while rebinding a thruster removes the component (apply_ship_rebind), and
on_thruster_input_binding_removed despawns its action and zeroes its
throttle. The save reads live components (live_input_mapping), so a load
keeps an own key, and keeps the Main Drive for a thruster with none.
The autopilot still flies every thruster.
apply_ship_rebind captures a key or mouse button first
(InputSources::captured_desk), else a pad button (captured_pad), and
captured_binds replaces only the half of that device: a key keeps the pad
half, a pad button keeps the key. Every common pad button but Right Trigger 2
(the guns’ trigger, which sections may share) belongs to a Flight or Always
action and is refused.
The Ship pane (nova_interface/src/ship/rebuild.rs)
lists lost sections while docked to a station that offers Rebuild, by
station_offers (nova_gameplay::markers), the one gate the Inventory pane’s
Trade and Storage also read. A lost section whose prototype is not in the
station’s StationBuilds (nova_gameplay::markers, read through
StationPartner.builds) is refused before payment, knowledge rather than
materials. Its plan
is plan_section_rebuild (nova_gameplay::inventory): hold parts first, the
rest bought at ask, refused when the station lacks stock, the ship lacks
credits, or rebuild_anchored finds no live mate for the lost section’s
sockets. A confirmed rebuild moves parts and credits (cause Rebuild on both
parties), pushes ShipInventoryReport::Rebuilt (OnRebuilt), and triggers
SectionRebuildRequest; rebuild_design_section spawns the section again at
full health through the same per-section spawn the ship was built with.
Swapping a section
While docked to a station that offers Refit, the Ship pane footer’s
Catalogue button, left of Fit and Reset, opens the parts gallery
(nova_ship::gallery, the editor’s picker) over the pane
(nova_interface/src/ship/catalogue.rs). A placeable part the
selected live section can be swapped for gets Swap as the focus card’s second
verb (GalleryAlternate), labelled with the swap price
(Swap PDC-2, 100 cr), or a second note line with the swap refusal, when
section_swap_family
accepts the part, and only when the part’s prototype is in the station’s
StationBuilds (nova_gameplay::markers, read through
StationPartner.builds). The family rule is
the same SectionKind::class and the same SectionFootprint read from the
authored collider, and another prototype. plan_section_swap checks the
gate, the family and the payment;
plan_recipe_payment (nova_gameplay::inventory) is the one payment rule a
rebuild and a swap share. On a click, swap_build_fault runs the generator’s
post-build checks on the ship the swap leaves: one socket graph, clear exit
lanes and the thrust floor (ShipMeasure::of_working, ship_measure.rs,
shared with nova_world_base::ship_layout; a collapse-disabled drive pushes
nothing, and the flight computer count binds authored hulls and the
generator only). Only a fault the swap adds refuses it. A confirmed swap moves parts and credits (cause
Swap on both parties), pushes ShipInventoryReport::Swapped
(OnSwapped), and triggers SectionSwapRequest. swap_design_section
(nova_scenario/src/objects/spaceship.rs) makes the root’s design inline,
points the section at the new prototype, moves the root’s pinned health by
the two parts’ pool difference, and respawns the section at full health with
the binding the old part flew with. The old part is not refunded. A freeze
and a save keep the inline design, so the swap persists.
Adding a section
The catalogue lists each part that is not hide_in_editor. A host that
carries a GalleryListing (the Ship pane; the editor’s gallery carries none)
also shows an All/Available pair after the filter box
(GalleryState.available_only, GalleryAction::Available,
catalog::browsable‘s available_only argument): Available keeps only the
listed parts with placeable: true and resets the selection to the first
tile, All brings every listed part back, and Open/Browse reset the switch
to All; the count line reads N of M parts whenever the switch or the filter
narrows the grid. A part the station’s StationBuilds does not name, or
whose recipe the player’s hold and the station’s stock together cannot carry
(plan_recipe_payment finds a missing part), has placeable: false: its
tile text, border and focus card title are muted, a theme surface veil greys
its 3D preview, it has no tile line, Place on its card is disabled with the
note as its tooltip (This station cannot craft this section, or the
missing-part refusal), the tile itself carries that same note as its hover
tooltip, and Enter and Q do not take it. Credits do not mute: a part the ship
cannot pay for stays placeable with Needs 400 cr on its tile and the credit
refusal as its note. A station builds id that GameSections lacks panics
when the catalogue opens; station lint refuses it first. Every tile’s label
strip is a fixed height (LABEL_H): the name sits left on one clipped line,
with the GalleryEntry.tile line (a price or a refusal such as Needs 300 cr) right-aligned beside it when the part has one, and the category sits
alone on the line below. A placeable GalleryEntry carries a short tile
line, its price (180 cr) or credit refusal, and the price or the whole
refusal as the focus card’s note. Its recipe packs one GalleryRecipeItem
row per item, with no gap, at the top of the card’s clipping details: the
item’s icon, a quantity cell reading covered/count (covered is held plus
bought, e.g. 1/2, Danger-coloured when the item is short and Primary
otherwise), the item’s name, and, only when something was bought, a right
cell with the bought items’ total cost (GalleryRecipeSupply::total_cr,
e.g. 40 cr).
GalleryRecipeItem::supply is a GalleryRecipeSupply that splits the count
into held, bought and short, with the item’s ask, the price
plan_recipe_payment charges per ItemPrice::buy_cr for the bought
share; its new panics when the
split does not sum to the count. Its tooltip, pointer-only and shown only for
an item the station sells, lines up Required N, then only the sources that
apply: From the hold N, Bought N at <ask> cr, <total> cr, and
Missing N (a short part is not placeable but its row stays at full alpha).
supply: None is a part the station does not build: the row is muted, with
the plain required count, no price and no tooltip. The split is
pointer-only; a keyboard user reads the quantity and total cells, the whole
refusal on the note line and the Swap button’s price. Place on the focus
card puts a part in hand (HeldPart, shared with the editor).
While a part is in hand, the placement ghost
(nova_interface/src/ship/ghost.rs) casts the pointer’s ray into the Ship
view, takes the nearest block it enters
(pick_ship_block), and snaps to the target of that section nearest the hit
(nearest_link_point over ghost_targets). A target is a free socket whose
outward lane leaves the ship without entering another block and where
section_add_clear finds the part clear, so a section inside the hull or a
socket a part covers is never a target, and a block with none shows no ghost.
free_link_points (section_add.rs) is every socket no design section mates,
live or lost, so a lost section’s socket stays reserved for its rebuild. No
content marks an attachment point; every hull, base, generated or modded, has
them. section_add_pose places the part by the player’s MountPose
(ShipRuntime::mount) through MountPose::snap: the mating socket is
MountPose::source steps on from the part’s natural_link_point (the socket
facing most nearly opposite) and the roll is MountPose::roll quarter turns
(snap_placement), using link_point_pose. The editor’s snap.rs solver
and PlacementPose use the same MountPose, so R (ship_part_roll) and F
(ship_part_socket) step it alike; in the Ship pane those actions follow the
tilt keys and act only while a part is in hand, and the pose resets when the
part in hand changes. Targets are chosen at the default pose, so a step never
moves the ghost to another point. It refuses an occupied point and a part
with no socket. section_add_clear adds the refusal for a collider overlap
with a design section it does not mate (section_colliders_overlap), and
section_add_fit adds a post-build fault the add makes (add_build_fault:
the swap’s checks through refit_build_fault, with placement_blocks_an_exit
for the lanes) and refuses Obstructed when the part’s collider overlaps a
collider outside the ship. The Ship pane passes the docked station’s
(StationSite::partner_colliders: the direct child sections of the partner
root and of every other ship docked to it, such as its parked hulls, in
ship-root space, within PARTNER_REACH_MARGIN cells of the ship’s reach),
and sync_ship_station_blocks draws the same boxes as dim outlines in the
Ship view, not selectable. A swap does not check the station. The ghost block
shows at the pose with a Nominal outline for a valid add and a Danger outline
for a refusal, and its callout beside the pointer shows the refusal, or the
price with the parts the hold gives and the parts bought at the station’s ask
(RecipePayment): the click buys exactly those, so it is the quick buy. A
click with a valid ghost raises
SectionAddCommand; a click with a refused ghost puts the refusal on the note
line. A click on a section badge commits the same way (commit_placement),
since the badge stops the click. The part stays in hand for the next add. With
no part in hand, a click on a block selects its section. While a part is in
hand, the Ship panel shows the Placing block (ship/held.rs) in place of the
selection: the part’s icon and name, its price (the aimed placement’s
RecipePayment, else plan_recipe_payment of its recipe), the socket and
roll of ShipRuntime::mount, and the note line; Delete is hidden.
Q over empty space or Esc (close_surface_from_menu_keys, before it closes
anything) puts the part away, and the footer names Q/Esc Put away.
plan_section_add checks the gate, the fit, the binding and the payment (plan_recipe_payment,
the full recipe). The new section’s binding is added_section_binds: the
first live bound section of its class by id lends its binding; with none, a
weapon takes an empty binding for Rebind, a thruster none (it fires on the
Main Drive), and a mining emitter the first of ADDED_MINING_KEYS no emitter
holds, refused when all are held. Its id is added_section_id: the greatest
id of its class plus +, so it sorts last and takes the next code without
moving another. A confirmed add moves parts and credits (cause Add), pushes
ShipInventoryReport::Added (OnAdded), and triggers SectionAddRequest;
add_design_section (nova_scenario/src/objects/spaceship.rs) makes the
design inline with the section appended, writes the binding into the player’s
input_mapping, grows the root’s pinned health by the part’s pool, and spawns
the section at full health. reselect_swapped_section selects the new section
once it is live, and ship_input eases the orbit pivot onto it at the same
radius. ship_input consumes a selection only when the section is in the pane
list, because an added section is selected before ShipSections lists it.
The Ship side panel scrolls under the wheel while hovered
(scroll_ship_panel), and the camera zoom yields to it. The pane footer names
only the keys that apply now (show_pane_input_hints).
Deleting a section
While docked to a station that offers Refit, the Ship pane’s ship_delete
key (Delete) and the panel’s Delete button raise SectionDeleteCommand for
the selected live section (nova_interface/src/ship/delete.rs).
ResolvedSite::plan_delete refuses, in order: no dock, a dock with no
Refit, the player’s port the dock holds by (ResolvedSite::port), a fault
remove_build_fault finds (the swap’s checks through refit_build_fault,
which can only open lanes, plus the last WORKING flight computer as
NoController and the last WORKING weapon of an armed ship as LastWeapon,
so a dock never hands the neutralize rule a beaten ship:
LiveSection::working reads SectionInactiveMarker into
RefitPlacedSection::working, so a collapse-disabled computer, gun or drive
counts for nothing, the thrust floor reads working drives only, a ship with
none working may shed its dead ones, and an unarmed ship has no weapon to
keep; the mass count is not checked), and a hold too full for the parts.
The parts are the strip rule’s: plan_section_yield at the section’s health
share. plan_ship_delete is the one rule the button and the act share. A confirmed delete puts them in the hold, pushes
ShipInventoryReport::Stripped with the player ship’s own id as hull
(OnStripped), clears the selection and triggers SectionRemoveRequest.
remove_design_section (nova_scenario/src/objects/spaceship.rs) makes the
design inline without the section and drops its input_mapping entry
(remember_lost_section_binding keeps no binding for an id the design no
longer holds), then triggers SectionStripRequest, which prunes the section’s
mates, lowers the root’s pinned health by its pool and despawns it.
reclad_round_removal (shell_skin.rs) observes the same request and
re-clads only the cells round the hole. No credits move. The per-frame
update_ship_delete_verdict caches what a delete of the selection gives
back or the refusal (ShipRuntime::delete_verdict, a SectionDeleteRefusal
with the reason line and, for a split, the sections that would lose the
dock: every piece of RefitBuildFaultType::Disconnected { pieces } but the
one holding the docked port), with or without a Refit dock. The Salvage
block shows while there is a verdict, with the Delete button, a compact
Danger button, as its action. When the delete can go, the block stacks one
part tile per part it gives back down a column (pane::spawn_part_tile,
the tile the Inventory strip block draws), or nothing to salvage, over an
enabled Delete. When it refuses, Delete is muted with the reason as its Tooltip
(delete_refusal_text: no prefix and no code, the title names the section),
the Ship view outlines the severed sections with the refused ghost’s Danger
material (update_ship_blocks) and fills them with a translucent Danger fill
(sync_ship_target_face, the block fill’s only writer, which restores the
family fill when the verdict clears; ShipRuntime::delete_severed), and the
Delete key puts the same reason on the note line. The note after a delete
(delete::delete_yield_lines) opens with deleted <code> to the hold: then
one +n Name line per part, or reads deleted <code>: nothing to salvage
when it gave back none.
Stripping a docked wreck
Docked with a hull the player may loot (neutralized, or a derelict with
LootableShipMarker) that is not a living station, the Inventory pane shows a
strip block over the partner column (nova_interface/src/inventory/strip/):
the Strip button that raises HullStripCommand and four meters: Total (the
job’s progress, the time), Step (the running section, shown while a job
exists), Sections (taken over taken plus live) and Hold (used over capacity,
with the plan’s mass or a held section’s need). Part tiles show the job’s
yielded over yielded plus the plan of the live sections at their current
health, recomputed each frame; yielded is what the strip recovered, not
what the hold carries now. The
Credits row’s Take all writes TakeAllCommand: plan_loot_take plans
what fits of every hold stack in id order, then of every stack in a dead
station’s StationStorage (LootTake::taken_stored, the player’s own
stored items; None for a ship or a living station), then the credits. A
hold part moves through the Take rule; a stored part leaves the storage
with one ShipInventoryReport::Withdrawn (OnWithdrawn) per stack. A
strip runs the same take first and again before the berth, so it never
demolishes a hull with loot or storage in it. plan_hull_strip
(nova_gameplay/src/inventory.rs) refuses, in order: not docked, a strip
already running, a living station, the player’s own station
(OwnedStation, OwnedStationMarker), a partner the player may not loot,
a hull the player owns parked there (OwnedHullsParked, a
ParkedHullType::Owned entry of ParkedHulls, which despawns with the
station), and a take that would leave a stack or the credits behind
(NoRoom, read from HullStripPartner::loot). The strip block and
apply_hull_strip_commands both read it through plan_strip_press: on
NoRoom, OwnedStation or OwnedHullsParked the Strip button is disabled
and its tooltip names the reason, and a press moves nothing.
The plan sums each live section’s StripTarget::parts
(BuildRecipe::strip_parts_at: floor(count * strip_yield * health share)
per part) and its strip_duration_secs: STRIP_SECS_PER_PART (1 s) per
recipe part, at least STRIP_MIN_SECS (3 s). A small hold is no refusal:
the strip holds at the first section that does not fit.
A planned strip moves the loot (take_loot, the Take all path), then inserts
HullStripJob (nova_ship/src/sections/section_strip.rs)
on the player root. advance_hull_strip (inventory/strip/mod.rs) runs it on the
virtual clock with the pane open or shut, so it holds while the TAB
interface is open; the block’s progress line and the HUD mode chip
(STRIP <percent>%, or STRIP HELD: HOLD FULL) show it. It takes one live
section at a time, the one next_strip_section names: the farthest from the
berth (the partner’s port that holds the dock) in hops over ConnectedTo,
sections cut off from the berth first, ties in pane code order, the berth
last, so the rest stays joined to the berth. When a section’s timer ends,
plan_section_yield plans its parts; a hold without room holds the job
there (HullStripPauseType::HoldFull) until there is room. Otherwise the
parts go into the hold, the pane pushes ShipInventoryReport::Stripped
(OnStripped, once per section) and triggers SectionStripRequest.
strip_section takes the section off without the destroy path, so it fires
no burst, no OnDestroyed, no canister drop and no floating piece: it prunes
the section from its neighbours’ ConnectedTo, lowers the root’s pinned
health maximum by the section’s pool, and despawns it. A stripped section is
then a design section with no live entity, as a destroyed one is, so a
freeze, a thaw and a save keep it stripped with no save field. Before the
berth’s parts, the job plans plan_loot_take again over what a Give put
into the wreck during the strip, moves what fits through take_loot, folds
it into HullStripJob::looted for the end note, and holds under HoldFull
(need_g the mass that stayed) until the rest fits. The berth’s parts end
the strip: the job triggers DockingReleaseRequest for the player and
despawns the wreck, its hold, storage and balance empty; a retiring cell freezes
only the bodies still live in it, so the wreck stays gone. An undock, or a
dock with another hull, stops the job and keeps what it took; the job is not
saved.
Buying a hull
A generated living station sells whole inline designs that station_hulls
(nova_world_base/src/station_hulls.rs) lays out for its civilization from
the world seed and the station id: one per docking port but one, the count
the station’s generated layout carries, so the last port stays free for the
player. Roles cycle from a seeded start through the roles the
civilization’s advancement can build, and each hull is generate_ship at
that advancement, so the size ceiling tiers the list: a first-ring station
sells small hulls only. They ride SectorStation.hulls in the manifest
(checked by check_ship_design, refused on a dead station).
spawn_sector_station hands every offer to PendingParkedHulls, and
park_pending_hulls (nova_scenario/src/objects/parked_hulls.rs) spawns each
as a real ship once the station’s sections are live, in list order: a hull
for sale while two ports are free, docked by dock_parked_hulls. A hull takes
the first free port in design order where one of its own ports, tried in
design order, lets it stand clear: parked_hull_clear_pose
(nova_ship/src/sections/hull_buy.rs) poses it by parked_hull_pose, the
hull’s port facing the station’s, and rolls it a quarter turn at a time about
the port axis until its envelope overlaps no box of the station’s envelope,
of a hull parked before it or of the three-cell approach lane of another free
port (section_colliders_overlap). A design’s envelope (design_envelope) is
its section colliders plus, when it wears the derived skin (ShipSkin), the
collider box of each plate derive_skin lays: a plate stands in the empty
cell beside its section, so two clad hulls whose sections only touch clad
into each other. The hull’s port face stands one cell off the station port’s
(BERTH_GAP, nova_ship/src/sections/docking_section/port.rs): the reach
two sleeves bridge and the capture_distance the dock is graded against, so
the pair needs no exemption and a plate beside either port keeps its cell.
The gap gate allows GAP_SLACK (0.0625 units, 0.625 m) over
capture_distance for the f32 noise of absolute world positions, so a berth
posed at exactly one cell holds out to the advancement saturation radius; a
player’s DOCK gets the same 0.625 m past the authored 10 m, and a berth a
half cell or a cell off still fails. A hull that fits nowhere is not parked,
with a warning, so a station sells at most its ports less one. At the session’s
starting station the fresh player ship berths first: outward_berth
(hull_buy.rs) picks its berth, the most outward port first, with nothing
parked; the parking keeps that port as the free one and parks the sales round
the ship’s envelope. berth_at_start (nova_world_base/src/start.rs) then
berths the ship by the same rule, clear of the parked hulls
(parked_envelopes). A berth that differs from the reserved one is logged as
an error; a ship that fits at no free port is logged as an error and stays
undocked where it spawned.
A parked hull is inert: no driver, Neutral, every capability off, not
lootable, no credits. It carries ParkedHull(station) and ParkedHullType
(Sale, Owned or Hire { slot },
nova_ship/src/sections/hull_buy.rs); the
station’s ParkedHulls despawns them with it. A parked hull is no persistent
body of its own: freeze_station records each in FrozenStation.parked
(FrozenParkedHull: name, listing, pose relative to the station, ship), and a
thaw parks them again where they froze. A station whose only docks are its
own parked hulls retires.
The Station pane’s “Your ships” and “Ships for sale” lists
(nova_interface/src/station/shipyard.rs) show every hull parked at the
docked station as a card (spawn_hull_card_head in station/mod.rs): a
view of the hull and one line with its name, length and price, then a Buy
button, or the refusal, and an Info button. A ship the player owns
(Owned) lists under “Your ships”, above the hulls for sale, and says “your
ship” in place of the price, over a Board button. Each list shows only while
it has a card; a button is named by the hull’s place among the station’s
owned and for-sale hulls (HullBuy-{i}, HullBoard-{i}, HullInfo-{i}).
Info (station/hull_info.rs) sets ShipViewSubject to the card’s hull and
opens the Ship pane: ShipSections lists that hull’s sections,
assign_section_codes codes them, and the view turns, zooms and selects as
for the player ship. Every edit is off while the
subject is set: Catalogue and the part in hand, Delete, Rebind, Repair, the
rebuild list and the station outlines. A header over the Ship panel holds the
card’s line and Close, back to the Station pane; leaving the Ship pane any
other way clears the subject too. The view
(hull_preview.rs) mirrors the parked hull’s own meshes and materials,
posed in the hull’s frame, into a scene on the card’s render layer, turned
slowly about its vertical axis and lit by a key and rim light on the card
layers only. mirror_hull_previews follows the live hull, so damage and a
changed part show; the cameras render, and the mirrors update, only while
the Station pane is on screen.
plan_hull_buy checks, in order: docked, Refit, and the player’s balance
against the price. A hull for sale costs hull_price_cr, every section’s
recipe at ask, in full: the station pays nothing for the old ship, which
stays the player’s. A confirmed buy moves both balances (cause ShipBuy),
pushes ShipInventoryReport::ShipBought (OnShipBought), and triggers
HullBuyRequest. Board triggers the same request for an Owned hull and
moves no credit and reports no buy. swap_parked_hull
(nova_scenario/src/objects/station_hulls.rs) keeps the player’s ship root,
so the HUD, the camera and the scenario id keep their owner: it freezes both
hulls, rebuilds the root as the parked hull (rehull_ship) at its pose with
the start-role bindings for the new hull (start_role_bindings: turrets the
trigger, railguns R, torpedoes F, the first emitter V and each later one the
next ADDED_MINING_KEYS key; no old binding carries over), and parks the old
ship where it stood as Owned with OwnedShipMarker.
The old ship keeps its hold; the Inventory pane lists the hold of a docked
OwnedShipMarker ship as storage. OwnedShipMarker (nova_gameplay) is
the one record that a ship is the player’s: an Owned parked hull carries
it, and PlayerFleetMarker requires it, so an escort does too; a neutralized escort
loses both. The root takes the bought hull’s name. The old port’s despawn
releases the dock, and redock_bought_hull docks the new hull with the
station, giving up with a warning after REDOCK_FRAMES. A docked ship
that is neither the station’s own parked hull nor a plain AI hull with an id,
which freezes inside the station’s record, keeps the station from retiring,
so the player undocks the bought hull normally.
After its hulls for sale the station lists one to three escorts for hire
(HullOfferType::Hire { slot }), each parked as ParkedHullType::Hire but
not docked: it coasts beside the station, inert like a hull for sale. The
escorts stand on a line through the station, alternately ahead and behind,
hire_offer_offsets (nova_ship/src/sections/hull_buy.rs) spacing each past
the station’s reach and the one before it plus HIRE_OFFER_CLEARANCE
(100 m); a reach is the farthest section’s centre plus its collider’s
half-diagonal (design_reach). In a gravity well that holds the station,
hire_offer_place rotates the station’s pose and velocity about the orbit
pole by the offset over the orbit radius, so an escort shares the station’s
orbit a little ahead or behind and does not drift; in flat space it stands
along the station’s +Y with the station’s velocity and spin. A generated
station opens its ports level: no port’s DOCK corridor runs along Y, and each
escort keeps HIRE_OFFER_CLEARANCE off every corridor axis. Each parked
record keeps the hull’s pose and motion relative to the station; a thaw, in
flat space or in a well, puts each escort back at that pose with the
station’s velocity, its spin about the station and the saved relative
motion, so a hire never moves the escorts it leaves. Load refuses a
non-finite saved motion. Fighting roles (scavenger, armored) the
civilization can build, cycled from a seeded start, none where it builds no
fighting role. Each keeps its roster slot, so escort_id (station id, stem
<stem>_<index>_escort, the slot as index) gives one escort one id however
the earlier ones were hired. The Station pane’s “Escorts for hire” list
(nova_interface/src/station/escorts.rs) shows each escort coasting beside
the docked station on the same card as a hull for sale, with a Hire button
or the refusal. plan_escort_hire
(nova_ship/src/sections/escort_hire.rs) checks, in order: docked, Refit,
a free fleet slot (FLEET_SIDES, starboard then port, so the cap is two),
the player’s balance against the price, and the station’s balance plus the
price. The price is escort_price_cr, hull_price_cr times
ESCORT_HIRE_FACTOR (3/2). A confirmed hire moves both balances (cause
EscortHire), pushes ShipInventoryReport::EscortHired (OnEscortHired)
and triggers EscortHireRequest. hire_station_escort
(nova_scenario/src/objects/fleet.rs) freezes the offer, despawns it and
thaws the record as the escort under escort_id, where the offer coasted
and at its velocity: an AI driver leashed at ESCORT_LEASH, Player-sided,
every capability on, with an empty hold and no credits, then adds
PlayerFleetMarker and AIFollow.
update_follow_flight (nova_ship/src/input/ai/follow.rs) flies the escort
as the combat pilot flies: a held MatchVelocity of the leader’s heading at
a lagged speed plus a closing speed the hull can brake from (ai_radial_speed,
at most AI_FOLLOW_CLOSING_SPEED, 2 u/s). Only a speed gain lags
(AI_FOLLOW_FEED_LAG, 2 s); a turn or a slow-down is flown at once. It never
holds a velocity against the leader’s travel. The held velocity refuses the
RCS (MatchVelocity { rcs: false }), so the escort turns onto the error and
burns its drive: an RCS push turns no hull and lights no drive plume, and an
escort that took up the leader’s start on it slid along as if dragged. The RCS
is allowed for two things. It dodges: inside the leader’s clearance (both
arms plus 10 u), or when the leader closes on the escort fast enough to reach
that clearance within AI_FOLLOW_DODGE_LEAD (2 s), about the time the drive
takes to turn. And it trims: within AI_FOLLOW_TRIM (6 u) of the goal, with
the error and the speed lag inside the flight computer’s crumb band
(attitude_deadband), once the leader has held its velocity for 1 s. A leader
that starts, stops or turns leaves the escort to its drive. So a leader that starts from rest opens a gap before its escort follows, and
only the escort’s drive and computers move it. The goal is
formation_goal. A leader at or above AI_FOLLOW_TRAVEL_SPEED (1 u/s) sets
the frame with its travel, not its nose: the slot is the side times both arms
plus AI_FOLLOW_GAP (20 u), AI_FOLLOW_TRAIL (10 u) behind the leader. A
slower leader leaves the escort where it is, pulled into AI_FOLLOW_HOLD
(15 to 40 u between the hulls), so a hull turning in place keeps its escort
still. The leg is planned in the leader’s frame with plan_leg, round the
leader, every other hull and every sized body by the escort’s arm plus 10 u.
plan_leg cannot steer out of a clearance it starts inside, so an escort
inside the leader’s clearance first flies straight out of it.
Far from its goal the escort points at it; within 20 u it points where the
leader points. It keeps its leash centred on the leader and gives way to a
combat engage. A leader that locks its escort within the far hold gap is
coming to dock: the escort coasts on its own velocity, with no dodge, no
clearance and no RCS, and the dock admits it as any calm ally. Docked, it
replaces its maneuver with a STOP and holds the pair, as a docked passive
driver does. At the undock it flies formation again, though the leader still
locks it, until the leader locks something else. A neutralized escort leaves
the fleet.
Docked at a living generated station (it wears a GeneratedBodyId and is not
derelict), the Station pane’s “This station” block
(nova_interface/src/station/station_buy.rs) offers the station itself. No
authored service gates it: every such station is
for sale. The price is station_price_cr
(nova_ship/src/sections/station_buy.rs): every live section’s recipe at ask
(hull_price_cr), the station’s hold at ask and its credits, times
STATION_OWNERSHIP_FACTOR (2/1). The player’s storage there is already the
player’s and is not priced. plan_station_buy checks, in order: docked, for
sale (StationSaleType), not owned, and the player’s balance. A confirmed buy
charges the player (cause StationBuy), pushes
ShipInventoryReport::StationBought (OnStationBought) and inserts
owned_station() on the station root: OwnedStationMarker,
Allegiance::Player and OWNED_STATION_NAME (“Owned station”). The
station leaves its civilization: its AI targets the player’s foes, the
relation model, the HUD and the Map read it as the player’s side, and its
name no longer carries the civilization’s. The price goes to the
civilization, so the station keeps its balance, stock and services. An owned
station admits the player whatever the player’s side (see “Who lets a dock
in”), and sells its hold to the player at bid (TradePriceType::Owned in
plan_item_trade); storage
withdraw was already free. Its services still price at ask, its credits are
its trading float (Take does not reach them), and it earns nothing: no
income rule exists. Ownership has one source of truth: the live marker, and FrozenStation.owned
in the frozen record a retire or a save writes and a thaw reads back. The side
and the name freeze with the hull (FrozenShip) and the body (FrozenBody)
as every ship’s do; the side alone cannot mark ownership, since a generated
civilization can be on the Player side. The chart keeps ChartStation.owned
from the dock and the undock, and the Map’s chart view names the station
OWNED STATION in the ally colour. The generator still describes the station
as its civilization’s, a pure function of the seed; the frozen record
replaces that description once the cell retires or saves, and nothing at
runtime counts a civilization’s stations.
SectionKind variants (one module per kind under crates/nova_ship/src/sections/;
turret_section/ and torpedo_section/ are directories, not single files):
| Kind | What it does |
|---|---|
Hull | Passive structure/armor. Just a render_mesh. |
Thruster | Forward thrust (magnitude); drives the exhaust visual. |
Controller | Attitude controller (steering_lag, max_torque); lag derives the internal PD gains, torque feeds the hull’s attitude envelope (see below). Attitude hardware only - what a ship is PERMITTED to do is ShipCapabilities on its root. A ship needs one to be steerable; several SHARE one attitude loop. |
Turret | Aims and fires bullets. An authored joint tree (hinges + muzzles, each joint with its own offset/axis/speed/limits/render_mesh), section-wide muzzle_speed + authored bullet_damage + bullet_kind, per-muzzle fire_rate, optional ammunition: Limited(n) drawing PdcRound on reload. |
Torpedo | Torpedo bay. Fires guided torpedoes of an authored torpedo_type (name, tint, max_speed, weave_angle, weave_rate) that detonate an Explosive area blast (blast_radius, blast_damage), optional ammunition: Limited(n) drawing Torpedo on reload. The TYPE is the run-in - how fast and how evasively; everything else on the config is the tube. |
Railgun | Spinal lance. No traverse: the HULL aims it down muzzle_offset. Tapping the trigger commits, the bolt walks the bore for charge_seconds, and the shot leaves whether or not the nose is still on the target. The slug deals slug_damage to every layer it rakes; slug_power and not a layer count bounds it, optional rake_radius spends that budget on a wider corridor instead of unused depth, slug_speed x slug_lifetime is the reach, and recoil_impulse lands at the muzzle point so an off-axis mount yaws the ship. Usually ammunition: Limited(1) with a long reload drawing RailSlug. |
Docking | Docking port. A cylindrical, rotationally symmetric collar that holds this hull to another one. capture_distance, capture_angle and the two relative-speed ceilings are the envelope a DOCK is graded against; the sleeve that reaches across once the dock holds is an animation track, not a collider (see below). |
CargoIntake | Cargo intake. Takes the whole items of a CargoCanister that fit the hold’s free mass into the ship’s ShipInventory, in item id order (CargoCanister::take_fitting), and leaves the rest in the same canister with the same CargoCanisterRuntimeId; and drops the Inventory pane’s jettisons from a CargoIntakeEjectionQueue, front first, one at a time while the door is open and the birth point is clear. Each live intake owns a detached static Avian Sensor trigger, aperture_width by aperture_height by capture_gap deep, in front of the Cuboid collider’s -Z face: not a child collider, since the workspace has no collision layers and a sensor in the ship’s compound would join it for mass, bullets, locks and overlap reads. detection_range opens the door for a canister with at least one item that fits the hold’s free mass (visual only for a take); a canister whose collider touches the trigger gives up every whole item that fits, at any speed, and is despawned once empty; a canister in two triggers goes to the first intake in entity order, and the next sees only what is left; eject_speed sets a drop; the IntakeDoor track gates only the drop. The door, drop and take trigger events that ship_audio/machinery.rs voices (cargo_intake_section.rs). |
Mining | Mining emitter. Each emitter deploys on its own key: a player ship’s input_mapping entry for the section becomes a SpaceshipMiningInputBinding rig that sets that section’s MiningSectionHeld (input/player/weapons.rs), and lint and spawn refuse a player mining section with no entry or an empty one, and two mining sections that share a source. While it is held, drive_mining_emitters opens the StowDoors track, then extends the StowLift tip; on release the tip goes in before the doors shut (mining_section.rs). Each change of the doors’ target triggers MiningDoorsMoved, and ship_audio plays the authored door_open_sound or door_close_sound at the section; the spawn snap and a held key play nothing. Each fully deployed emitter pulses on its own clock every pulse_interval_seconds. A pulse casts a ray from its collider’s -Z face against asteroid colliders within reach without a travel lock, picks the nearest hit (including barren rock), and carves carve_radius_cells there if it holds ore. Other bodies do not occlude this asteroid-only ray. Ore is paid per newly emptied corner after the remesh, then leaves the rock as canisters one at a time. A passed pulse plays the authored pulse_sound at the hit and, in a rendered app, flares the additive beam shader (assets/shaders/mining_beam.wesl) and throws a fixed spark burst. A refusal is a MiningPulse with a MiningRefusalType, plays the authored refusal_sound once at the emitter face and changes nothing (nova_scenario/src/mining.rs). |
run_cargo_intakes publishes CargoPickupReadiness for each live intake/canister pair on the fixed clock, including out-of-range canisters. A pair names only the ship, intake, canister and ready; it carries no pose. The HUD draws its live pickup sight for one published pair from rendered GlobalTransform poses, with the face from cargo_intake_face, the helper the fixed pass also uses. The canister must be within CARGO_PICKUP_SIGHT_RANGE (200 m) of the player’s ship, with no lock needed: a travel-locked canister first, else the nearest intake face, then the lower entity IDs (pickup_sight.rs). No pair in range draws no sight. ready means this pass takes at least one item. A take that empties the canister despawns it in the same pass, so the sight clears with it; a canister with items left stays a candidate. CargoCanisterTaken voices every take.
GameSections(Vec<SectionConfig>) is the resource of section blueprints.
Every base prototype is GENERIC and authored under
crates/nova_authoring/src/base_content/sections/, one module per family
(hull, controller, thruster, turret, torpedo_bay, railgun,
docking_port, cargo_intake, mining_beam); a mod that brings modelled craft declares their parts itself. The explicit section_catalog() is
generated into assets/base/sections/base.content.ron by content -- gen and
merged into the resource by
crates/nova_assets/src/merge.rs. The outer-skin cladding is not a prototype at
all: a ship’s skin is DERIVED from the structure it wraps by nova_ship’s
shell_skin (below), so no id names a plate. Look a
section up with
sections.get_section("basic_thruster_section").
The attitude envelope, and how controllers stack into it
How hard a hull may turn is DERIVED, not authored. AttitudeEnvelope
(crates/nova_ship/src/physics/attitude.rs) is two ceilings and the lower of
them:
alpha_max = min( sum(max_torque) / I , LOAD_LIMIT / (r * METERS_PER_UNIT) )
\___ propulsive ___/ \________ structural ________/
Iis the hull’s largest principal moment of angular inertia, read off avian’sComputedAngularInertia. No formula for it lives in Nova: it is the second moment of where the mass actually sits, so no curve in any single length can stand in for it.ris the structural arm, from the hull’s centre of mass to the outer FACE of its furthest live section.structural_armmeasures it off posed colliders, so its answer is in engine world units and the caller crosses it withMeters::from_enginebeforeAttitudeEnvelopedivides by it - which is whyMETERS_PER_UNITappears in the formula above and not in the code. The Ledger corvette reads 2.76 u, i.e. 27.6 m.BodyRadiusis a scenario-obstacle radius and is not it.LOAD_LIMITiscrates/nova_events/src/scale.rsandMETERS_PER_UNITiscrates/nova_events/src/units.rs- one definition each, shared by the physics and by the player-facing formatter innova_ui::units. Both halves of the world’s scale, and the boundary they sit on.- The ceiling comes out in rad/s^2 either way:
LOAD_LIMIT / ris (m/s^2) / m, so the scale cancels and no figure on this page moves with it.
The two structural loads are perpendicular components of one acceleration at
the tip, so they add as a VECTOR: alpha^2 + omega^4 <= (LOAD_LIMIT / r_m)^2.
A hull already in a hard turn has spent its margin, and its sustained rate is
sqrt(LOAD_LIMIT / r_m). Past that corner the centripetal load alone is over
the limit and the full ceiling comes back, so an over-spun hull can always shed
rate.
That returned ceiling is a MAGNITUDE, and the loop that spends it is what
keeps it pointed the right way. AttitudeEnvelope::available is paired with
sustained_turn_rate on the same PDController
(sustained_angular_speed): past that rate the PD drops whatever part of its
requested acceleration lies along the spin and keeps the rest, so the
authority can brake and steer but not tighten. Without the pairing a held turn
spends the returned ceiling on going faster and settles wherever the
controller gains run to - which is what the load limit exists to stop, and
what examples/systems/system_turn_limit.rs flies.
Two consequences to expect rather than debug. A small hull is STRUCTURE-bound, so fitting more computers to it changes nothing at all. A hull that loses sections shortens its arm and turns SHARPER than it did intact.
Every live controller torques the hull in parallel, so a hull with several of
them would multiply both its gains and the applied torque by the section count.
update_controller_stack_tuning
(crates/nova_ship/src/sections/controller_section.rs) prevents that: it runs
first in FixedUpdate (ControllerSectionSystems::SyncStack), derives ONE
ship-level attitude loop per root, and writes each live controller a share of
it into its PDController. The authored numbers stay put in
ControllerSectionTuning, which is what the pass re-derives from when a
controller dies. The smallest live steering_lag supplies the stack’s base
response.
The pass measures or it does nothing. A root with no finite, positive inertia
and arm yet - the spawn frame before avian has computed mass properties, or a
hull that has lost every live section and is no longer sized at all (see
publish_hull_radii) - is SKIPPED, and every controller on it keeps the tuning
it already had. Dividing by a missing arm writes an infinite structural ceiling
onto each PDController, which disables the load clamp for that tick.
The ship-level loop, for n live controllers:
- authority: the envelope above. Torque SUMS across controllers, with no curve and no cap of its own, because the structural ceiling already caps the result. Each controller’s share is its own fraction of the hull’s torque.
- P gain: DIVIDED by
stack_curve(n, 1.5) = 1.5 - 0.5 / n, which increases the effective steering lag so the stack brakes earlier and lands on the commanded attitude instead of sailing past it. - D gain: held at exactly one fastest computer’s worth. This is not tuning:
kd * dtcrosses 2 at two controllers on the shipped tuning, and past that the PD limit-cycles instead of parking (the corkscrew that used to follow a released maneuver).
ship_turn_rate (flight/guidance.rs) then sums the live acceleration shares,
which is why the flight layer is ordered after SyncStack. n = 1 is the
identity case.
The precision division is the one part of the stack that a structure-bound hull
does not pay for: it has no extra authority to spend, so a stack costs it a few
per cent of peak rate and a tick or two of onset in exchange for landing
cleaner. Measured in flight/tests/stacking.rs, which prints the table.
The spinal lance
Railgun is the one weapon kind whose aim is the HULL. It fires down the
section’s own -Z, has no joints and no cone, and that is why it is its own
SectionKind arm rather than a turret with a narrow arc: the turret code is
about cones and joints, and a lance has neither.
The whole cycle is one system, charge_and_fire_railgun
(sections/railgun_section/firing.rs), on the FIXED clock:
- The commit. A trigger pull moves
RailgunCharge::ReadytoCharging. The MAGAZINE gates the commit rather than the shot - committing an empty gun would burn a charge for nothing and hide the reload from the player - and the shell is spent only once the shot is certain. The section readsRailgunSectionInputand never clears it, so a held trigger is a gun cycling at its own cadence rather than a second mechanic. - The safety is LIVE through the charge.
WeaponsHotgates the commit and is re-read every tick: a gun safed mid-charge dumps the charge, snaps theChargecue back to 0 and KEEPS its shell rather than firing it into a friendly. That is the only way out of a commit; the trigger has none. A ship carrying noWeaponsHot(a bare headless rig) fires freely. - The charge.
charge_secondsruns offTime, and theChargeanimation cue is SNAPPED to the gameplay fraction every tick rather than played. Both travel times on the shippedcharge_bolttrack are zero for that reason: an authored travel time would be a second clock, and the bolt would reach the brake before or after the shot it announces. - The shot and its recoil, in the same tick. They have to share one, or an
impulse applied a tick late pushes a ship that has already moved on from the
pose it fired in. That is what keeps the cycle in a single system and earns
its
too_many_argumentsexpect. The bore is read off the RAW physics pose throughlocal_pose_in_root, notGlobalTransform, which inside FixedUpdate still holds the previous frame’s eased render pose. The slug inherits the muzzle’s full rigid-body velocity, swing included.
The slug is an ordinary nova_gameplay::rounds Pierce round with
layers: u32::MAX, so slug_power alone bounds the depth: it curves in wells,
is charged once per layer through pierce_remainder, and needs no second damage
pipeline. slug_damage is dealt FLAT to every layer (hit_bite does not scale
Pierce), while each crossing costs that layer’s MAX health divided by
pierce_power_multiplier - and a slug at 15,000 m/s always sits at that curve’s 3.0
ceiling, so the shipped 1800 power is 27 reinforced hull blocks.
Nothing that flies is 27 blocks deep, so the optional rake_radius spends the
surplus on WIDTH: a sphere of that radius trails the tip, tangent to it, and
everything it sweeps over a body the tip DIRECTLY hit takes the same flat bite
out of the same budget. RoundRake carries the arming, per body and for the
slug’s life; sweep_raking resolves the tip, the trailing capsule and the
charge order in three passes. Omitted, no RoundRake is inserted at all and
the slug takes the untouched narrow path. The base lance authors 10 m,
measured against 40 m in system_railgun_lance’s stand bank: both spend the
same budget, and the radius chooses whether it goes through the hull or across
its face.
One fixed step of a raked slug, as sweep_raking resolves it:
flowchart TD
T[Pass one: sweep the narrow tip] -->|direct hit on a body| A[Arm that body for the slug's life]
T -->|passes near a body| M[Nothing - a near miss is a miss]
A --> S[Pass two: the sphere trailing the tip by its radius]
S -->|each collider of each armed body| C{Inside the swept capsule?}
C -->|beside the track| K[Contact at its lateral offset]
C -->|ahead of the tip| N[Untouched until the tip gets there]
C -->|already charged| N
K --> O[Pass three: order by depth, then axis outward, then entity]
T -->|the tip's own contacts| O
O --> P[Charge each once through pierce_remainder]
P -->|power left| S
P -->|budget empty| E[Slug spent]
The capsule test is analytic - the corridor measurement from corridor_contact
against the sphere’s track - rather than a spatial query, because parry’s
intersection_test returns false negatives for shallow overlaps against a
capsule this long - a slug at 15,000 m/s sweeps 23 world units of the
capsule parry is being asked about per 64 Hz step, which is 234 m - and the
first
version of the rake, which queried, cut a corridor with its corners missing.
The body’s own colliders are the candidate set, so there is no query cap under
the power budget either.
Recoil goes through apply_linear_impulse_at_point AT muzzle_offset, never at
the centre of mass, so an off-axis lance yaws the hull every time it fires.
every_lance_puts_its_muzzle_on_the_brake_face pins the offset to the face for
exactly that reason: it is one number that is silently wrong in two ways at
once - the slug spawns inside the gun, and the torque is off by the same
distance. every_lance_holds_exactly_one_shell pins the other half of the
design: capacity 1, a reload of 1, and a reload delay longer than the charge, so
the reload is the tempo.
The lance’s sockets (lance_link_points, nova_authoring) leave the muzzle
face bare. A lance cannot traverse off its own line, so a socket there is an
invitation to bolt a plate into the bore;
no_lance_sockets_the_face_it_fires_through holds it.
The pilot-facing half is nova_hud’s bore sight
(crates/nova_hud/src/bore_sight.rs), the only instrument that says where a
hull points. It traces the bore and walks the crossed sections through the SAME
pierce_remainder, ringing each section the shot would destroy - a sight with
its own copy of the pierce math is a sight that eventually lies. It is gated on
WeaponsHot and drawn dimmed rather than hidden on an empty magazine.
The AI half is deliberately crude and recorded as such
(crates/nova_ship/src/input/ai/railgun.rs): a raider commits when its orbit
happens to sweep the bore across a target it is already fighting, inside about
eight degrees and 60% of the slug’s reach, with a per-gun cadence over the gun’s
own reload. It does not FLY the shot.
Docking ports, and what holds a pair together
A dock is deliberately NOT an attachment. Two docked hulls stay two rigid
bodies held by one avian FixedJoint, and every part of the mechanic follows
from that: no authority moves, no mass is merged, and either ship can leave.
The code is crates/nova_ship/src/sections/docking_section/: mod.rs is the
config and the sleeve’s state machine, port.rs the candidate search, and
connection.rs the connection’s whole life.
Finding a pair (DockingPorts, a SystemParam). DOCK names two SHIPS -
the presser and the ship it has locked - and the game picks the ports. A port
is eligible when it carries no DockedPort and is not SectionInactiveMarker.
Each pair is then graded on the STRICTEST of the two ports’ envelopes, so a
hull cannot loosen another’s rules by offering a lax port:
- the face gap between the two RETRACTED outer faces, not between section
origins. A port’s face is
(0, 0, -0.5)in its own frame, because a section’s outward axis is local-Zeverywhere in Nova. - the opposition of the two outward axes, within
capture_angle. Roll about the docking axis is not read at all: the ports are cylinders, so a twist is not a misalignment. - the relative linear and angular speed of the two BODIES. Absolute speed is irrelevant; two hulls holding formation at 2 km/s are stationary to each other.
best_candidate ranks survivors by (gap, opposition, first_id, second_id).
The two EntityIds are the tie-break, so two identical pairs resolve the same
way on every machine and in every replay.
Making the joint. DockingConnectionRequest re-runs the search - the
envelope is re-read at execution, never trusted from the frame the offer was
drawn in - and spawns ONE connection entity carrying both the
DockingConnection record and the FixedJoint. The joint is built with
explicit LOCAL frames, taken from the two body poses (p1, r1), (p2, r2)
and the midpoint m of the two faces:
#![allow(unused)]
fn main() {
FixedJoint::new(first_ship, second_ship)
.with_local_anchor1(r1.inverse() * (m - p1))
.with_local_anchor2(r2.inverse() * (m - p2))
.with_local_basis2(r2.inverse() * r1),
JointCollisionDisabled,
}
Both anchors land on m, and the second body’s basis holds the relative
rotation the two hulls met at, roll included. Do not use the global
with_anchor / with_basis: avian3d 0.8 converts a global basis as
basis * rot.inverse() where its solver needs rot.inverse() * basis, so the
joint holds the capture pose only when the two hull rotations commute. A
belly-up hull against a yawed partner does not, and the joint swings the
partner into the hull. A bare FixedJoint::new(a, b) carries
JointFrame::IDENTITY and would yank the two hulls into the same origin.
JointCollisionDisabled stops the two docked hulls from colliding with each
other: a skin plate or a greeble that reaches past a port face pressed on the
other hull every step, and the contact damage wore both down while they sat
still. Two hulls docked at one station are not joined to each other and
still collide.
Nothing writes a velocity anywhere on this path. The constraint is the physics’ job, so a pair that met while drifting keeps drifting; a hull that had been zeroed on capture would be a hull the solver has to fight.
The sleeves. DockingSectionState runs Retracted -> Extending -> Extended off the DockTube animation cue, and back down on release. It is
driven only AFTER the joint exists, so the tubes can never be what holds the
ships together. The track is a plain Translate on dock_tube* nodes: art
only, with no collider growth, which is why two mated sleeves may overlap
without the hulls touching.
Who lets a dock in. DockAdmission::refusal is the one admission point,
read by the DOCK chip and by the request. A ship target refuses with
DockRefusalType::Combat while Hostile, Enemy, answering fire or fighting
(a neutralized target admits). A station target (StationMarker) answers by
station_admission(allegiance, derelict, occupied, owned, hostile), in that
order: StationOccupied while every live port on its root is held (a held
port is no candidate in the port search; this names the reason so the HUD
can say it); admitted for a station the player owns (OwnedStationMarker)
or a derelict, on any side; EnemyStation for a living station on the Enemy
side (a generated station’s side is its civilization’s effective stance to
the player, written every frame by nova_world_base’s
sync_civilization_sides); HostileStation for a living station the ship
is hostile to by ship_relation (a Player station and an Enemy-flagged
ship, or a station answering the ship’s fire); and admitted otherwise.
FlightVerbHints::dock_refusal carries a station’s line (<NAME> REFUSES DOCKING, <NAME> DOCK OCCUPIED) while the travel lock is on it; the HUD
posts it once as a notice (hud_notice), not on the mode chip, and the DOCK
chip draws dim. A ship’s refusal stays the dark chip.
A station is a hub. A ship holds one connection (DockedShip); the port
search names no pair for a ship that already has one. A station holds one
connection per port: each connection to it carries the relationship
StationDock(station), the station lists them in StationDocks (Bevy
removes it with the last one) and carries DockedStation { helm, drives },
the station side of DockedShip, while it holds any. The station and every
hull docked at it are ONE assembly. A dock between two stations is refused.
One hull drives the assembly. DockingConnection.helm is DockedHelmType:
Neutral (a ship dock’s start, and the state after a lost holder),
Held(player), toggled by DockingHelmRequest from the dock_helm binding
(H), or Station(station), set at capture when the partner is a living
station and never toggled: helm_on_capture gives a living station its own
helm, a dead partner Held(player) (DockPartnerType::Dead: a derelict or
neutralized hull, ship or station, read from DerelictShipMarker and
NeutralizedMarker), and two living ships Neutral. A request to
take the helm of a pair with measurement_fault is refused, and
helm_request_admitted refuses every request on a station-held pair;
handing a held helm back is not refused. For a ship pair the driver is the
holder while Held, else the non-player endpoint, else second_ship. For a
station hub it is the player holding the helm of its own connection (only at
a dead station), else the station. A station drives with torque alone (its
bridge’s attitude loop as the helm wrench) and no thrust, so a station-held
assembly stays where it is. measure_docked_assemblies
(DockingSystems::Assembly) writes drives on every root (DockedShip or
DockedStation) and a DockedAssembly (combined mass, world centre of mass,
velocity, root-frame inertia, reach) measured every tick over N roots, with
the parallel-axis term written out. An unmeasurable assembly logs an error
once, drives no root and sets DockingConnection.measurement_fault on every
connection of it; the next measured pass clears it, and a new connection
starts clear.
DockedShip.drives is the single gate, read at the FORCE and at every intent
writer, so pilot, AI and scripted order are all held by the same line on the
root that is not driving: thruster_impulse_system, the manual balancer, the
RCS writer, the autopilot, the scripted orders (paused, never judged) and the
AI brains all skip it. The HUD mode chip and the dock chips read it too, so a
pair that is not driven never shows a live maneuver. The mode chip
reads measurement_fault first and shows HELM FAULT, because drives alone
cannot tell a fault from a hull off the helm, and STATION HELM on a
station-held pair. The HELM chip stays on the dock, dark, through
FlightVerbHints::helm_fault and helm_station, because HELM cannot take
the helm. The interface’s Map GOTO refuses only the docking partner: on a
pair the player does not drive, the autopilot’s skip holds the GOTO it sets
until the undock. On the driver, the autopilot, the flight authority, the
manual balancer and the RCS budget read the assembly, never the root alone. An AI driver does
not patrol, orbit or fight while docked:
update_passive_flight holds the pair on a Stop maneuver instead,
and update_ai_target clears its target, until the undock hands its route
back where it left off. A driver with no
DockedAssembly moves nothing: thruster_impulse_system skips it, and the
autopilot, scripted align, manual balancer and RCS writer each log an error
once and skip it. Scripted align aims from the assembly’s centre of mass.
apply_docked_helm_wrench replaces the attitude torque with one wrench split
over every root: a = I_asm^-1 tau, each root gets I_own a and the force
m (a x r) about the assembly centre of mass. RCS applies m_i * dv at each
root’s own centre of mass, so a translation adds no torque. The undocked path
is unchanged.
park_suppressed_docked_helms re-parks the attitude command of each root that
is not driving on the rotation it actually has, every tick, so a hull that
takes over or is released holds the direction it is already pointing.
Ending it. Three paths, and each removes only the connection it owns:
- the verb (
on_docking_release_request):DOCKpressed again, from EITHER hull of a ship pair or from the ship docked at a station, exactly asORBITpressed again disengages. A station names no single connection, so a request addressed to it does nothing; the other hulls docked at it stay docked. This is the only pilot-facing path, and it is ungated - a capability withdrawn while docked must never strand a hull clamped to something. - a destroyed port (
on_docking_port_removed_release), which frees the surviving port. - a broken endpoint (
release_broken_docking_connections), for a ship that is gone entirely.
nova_ship/src/sections/docking_section/tests.rs grades the search; the
system_docking_ports range grades what the SOLVER does with the joint.
Meshes and colliders (authorable)
Two authorable knobs decouple a section’s look and physics from the default unit
cube (crates/nova_ship/src/sections/base_section.rs). Unset content still uses
the unit-cube defaults:
render_mesh_transform(optional, on every mesh-bearing kind; for turrets it sits per JOINT in the joint tree) - an offset / rotation / scale applied to the section’s render mesh child ONLY, so a model can be re-seated visually without moving the collider or (for turrets) the joint tree. TypeRenderMeshTransform.collideronBaseSectionConfig(optional) - the physics shape:Cuboid { size },Sphere { radius },Capsule { radius, length }, orCylinder { radius, height }(the last three along local Y). These are BUILD-GRID dimensions and stay in engine world units for that reason: a collider is what avian receives, and one unit is one cell, 10 m on a side.Noneresolves to the unit cube (Cuboid { size: (1,1,1) }) - the shape every section had before colliders were authorable.base_sectionhands avian a density of 1, so this shape’s volume IS the section’s mass and a larger collider is a heavier one. The render mesh never contributes; a section masses its authored box. An integral cuboid at least one unit wide on every axis also derives aSectionFootprintof those dimensions.read_structureexpands that one entity across every occupied cell, and clearance emits one exit lane from every cell on its exhaust face. Other collider shapes remain one-cell parts.
Building a ship
A SpaceshipConfig (crates/nova_scenario/src/objects/spaceship.rs) has a
controller (None, Player, or AI), an allegiance, a capabilities set
(what this spawn is permitted to do), a required inventory stock, a required
lootable flag and a required credits balance. Stock must fit the resolved
hull’s hold at 100 kg per hull section; lint rejects heavier stock and load
fails if it escapes lint. lootable: true permits Take while docked even when
the ship was not neutralized; credits funds Buy and Sell against real stock.
A design is a ShipDesignSource, either an Inline ShipDesign or a
Prototype naming a catalog design by id with this spawn’s own
section_patches over it. The design carries the
integrity (the optional collapse_threshold, below), the presentation (the
derived cladding, the style and the cockpit voice) and a
list of SpaceshipSectionConfig, each placing one section at a position +
rotation relative to the ship root. That position is the one authored vector
that is not a distance: it is a BUILD-GRID CELL, so it stays a bare Vec3 of
engine world units and sections stack by whole cells of 10 m. It comes with a
source (Inline, or Prototype with its own SectionConfigPatch). The
player config carries the input mapping (section id -> key/gamepad bindings)
and nothing else, and refuses an unknown key rather than dropping it; the AI
config carries patrol/patrol_stops/orbit/leash/engage_delay.
patrol_stops names, one per patrol waypoint, the seconds the AI holds
station there before the next leg; patrol_stops_fault is the shared check
lint and the spawn both run against a mismatched length or a negative or non-finite stop.
Spawning: the base scenario bundle gives the root RigidBody::Dynamic; the
spaceship object adds SpaceshipRootMarker, and an observer
(insert_spaceship_sections) spawns each section as a direct child. Every
section gets SectionMarker, its Collider (the authored collider shape, or
a unit cube by default), SectionLinkPoints, ConnectedTo, and Health (base_section in
sections/base_section.rs), so the ship is one rigid body whose child colliders
each carry their own health.
See the block ship builders under
crates/nova_authoring/src/base_content/ships/ for complete generated examples,
and webmods/the-ledger/ledger_ships.content.ron for hand-authored ships built
out of modelled parts a mod brings with it.
The editor (crates/nova_editor) assembles ships interactively using
preview_section, which has no health or rigid body and never enters the
damage pipeline. A part placed from the gallery is a Prototype reference from
the moment it lands (spawn_section_node), and stays one when it is tuned:
inspect::edit_section applies the current patch, hands the edit a whole
config, then asks SectionConfigPatch::between for the delta back and keeps the
Prototype source with that patch. between is the checked inverse of apply
- it re-applies its own result and compares - and returns
Nonewhen the edit is outside what a patch can say (a different kind, a muzzle the prototype does not carry), which is the only case that falls back to anInlinecopy. So the editor never keeps a second list of which fields the patch reaches.
The same crate builds new instances of a section on a bench
(crates/nova_editor/src/asset.rs). Assets > + > Sections copies a prototype
or a plain hull into the mod and opens it as the document: the root holds the
Content, and the collider, mesh and each link point are nodes under it. Sockets
are SocketNodes with a Transform in cells, so the gizmo moves them on a 5 m
grid and a move is one undo step; lowered_item writes them back to link_points.
A project section reaches GameSections for the session through
sync_project_sections, not through a merge, because a merge refusal during
GameStates::Playing is fatal. The registrations go on leaving the document.
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. The cells around it give each plate a ShellShape
(sections/shell_shape.rs): a LEVEL (half a cell, or a whole cell beside
structure the skin climbs), a foot and a ShellEdgeType per edge (a chamfered
rim, a fold or a skirt round a convex hull edge, a stair ramp and its end, or a
wall), and four soft corners averaged over the cells that share them and held
at the level against a wall. Two plates
read what they share the same way, so their surfaces meet. A plate never peaks
in a point: its top is a level plateau cut down by straight chamfers, and a
staircase of cubes is clad as one ramp. The same structure always gives the
same skin.
- The skin clads only a face that carries an open socket. A socket authored
skin: Bare(LinkPointSkinType,sections/link_points.rs) is a DEAD END: it mates, places and joins the integrity graph like any socket, butread_cellsdrops it from theSkinStructure, so its open face gets no plate and no greeble, and the skin beside it does not climb it. The truss and the solar panel author both of their socketsBare, so a spoke end and a wing tip stop clean. Omitted meansClad. - A plate is a
SectionFixture(sections/fixture.rs):Collider,Health, density andHealthIsolated, but noSectionMarker. So it never joins the integrity graph, never counts toward the ship’s health, never wears a section’s damage effects (owning_sectionindamage_cracks.rsstops its ancestor walk at the first fixture), and never reaches the palette. Losing one costs the ship nothing it can DO - which is the line between a fixture and a section. - Each plate is a CHILD of the section it clads, so a destroyed section takes its own cladding down with it and nothing has to hunt the plates of a part that no longer exists.
- On a LIVE ship, derivation runs at spawn and nowhere else. The skin is a pure
function of the structure, so re-running it would grow back whatever combat
blew off;
shed_dead_fixturestakes a spent plate away and nothing puts it back. - A spent plate LEAVES rather than vanishing, the same finale a destroyed
section gets.
shed_dead_fixtures(sections/fixture.rs) drops itsChildOfwhere it stands, promotes its world pose to a local one, and gives it a kick outward from the hull, a tumble and aTempEntitytimer - inheriting the ship’s motion throughinherited_motion, so cladding does not hang in space while the hull flies out from under it. It is the SAME entity, so the meshes drawing it and the greebles standing on it come along untouched. Its colliders do not: shed cladding is DEBRIS - kinematic and untouchable - because a hull wears hundreds of plates and a dynamic body per plate is the cost a dying section already refuses. - The shed is CAPPED and runs on the FIXED STEP, in the phase the deaths
arrive in.
SHED_TICK_CAPbounds it to 24 fixtures a TICK andShedBudgetbounds it to 48 a FRAME; the rest are deferred, never dropped, because shedding is what removes theChildOfa plate is matched by, so a deferred one matches again next time. Two caps and not one, because a frame recovering from a long stall banks up to 16 fixed steps (Time<Virtual>’s 0.25 smax_delta) and pays for all of them at once - and the frames a hull loses its skin in are exactly the ones a collapse has already made the longest. A deferred plate still wears its collider, so a piercing round crossing it still burns a layer. ShipSkinPlugin { render }is split at the render line, not at the look line: the derivation and the shed are gameplay and run headless, andrendergates only the meshes hung on each plate by thedress_skin_plateobserver.
Cladding is OPT IN. The derivation reads a hull as unit cells, which the catalog’s cube sections are and the modelled parts a mod brings are not.
The editor’s live preview
The build view clads the ship being ASSEMBLED, and it re-derives rather than
spawning once: sync_editor_skin (nova_editor/src/skin.rs) runs after
sync_placement_ghost, hashes the structure it is about to read, and respawns
the whole skin when that hash moves. Nothing is patched and nothing is
diffed - the derivation is a pure function, so throwing the plates away and
asking again is both the simplest answer and the one that cannot drift. On a
384-plate ship a reflow costs about 2 ms and an unchanged frame about 0.1 ms;
a real build is an order of magnitude smaller than that, and the ghost only
travels in whole cells (placement mates sockets), so dragging a part does not
re-derive per frame.
Two things it does differently from the spawner:
- The part UNDER THE POINTER is structure, while its placement is legal. That is the feature: a hull is dragged about under the skin and the cladding closes around it before the click. A REFUSED ghost contributes nothing - it will not be built, so cladding it would draw a ship that cannot exist.
- A preview plate is DISPLAY ONLY:
ShipSkinMarkerand a pose, so the shareddress_skin_plateobserver still draws it, but noSectionMarker, noColliderand no health. The placement solver never counts one as a part, the pointer never hits one, and thePpipette cannot arm one.
Both readings go through read_structure (shell_skin.rs), so the lattice the
editor clads on and the lattice the flown ship clads on cannot drift - and the
build state carries the toggle into the ShipDesign’s presentation the
scenario spawns, so what you see in the editor is what you fly.
The plate vocabulary and skin styles
The derivation works out far more than it keeps. read_plates
(sections/skin_reading.rs) is a SECOND PASS over the finished plates that reads
it back out as a PlateReading each: which way the plate faces, what its top is
shaped like (Flat / Step / Ridge / Peak / 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 several reliefs and not one, which matters because it is
most of every ship. An edge falls for exactly one reason - open space stands
across it, so the top chamfers down or folds round the hull edge - so counting
the falling edges of a half-cell plate says how many ways it falls: none is
Flat, one is a Brink (the straight edge of a hull), two opposite is a
Ridge (a run one cell wide), four is a Peak (a lone cell), and any other
count is a Spur (a tip or an outer corner). A whole-cell plate is a Step
whatever its edges do. Summing the directions of the corners beside a falling
edge 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.
A ShipStyleConfig (sections/skin_style.rs) is CONTENT resolved by id out of
GameStyles, exactly as a section prototype resolves out of GameSections. It
carries a StylePalette - a SurfaceFinish for the plate top and one for its
wall, the hidden floor keeping an engine finish - a list of decoration
fixtures, each with a model AssetRef, a health and a collider, and the
greeble_rules that place them. 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. Physics density is not authored:
every piece stands at DECOR_DENSITY, the same as the cladding it sits on.
greeble_rules is a GreebleRules (sections/greeble_solver.rs): LINK TYPES,
each with the link types it connects to, and GREEBLES, each with a fixture
(or None for empty space), a weight, a rotate set of quarter turns, a link
type on each of its four link points (up, right, down, left), near
terms and a max_count. ShipStyleConfig::compile_greebles checks the rules
and resolves each fixture id once; a fixture whose collider is wider than one
cell across the plate (x or z above 1) does not compile, because no plate
can seat it. A rule set that does not compile is an Error
at content lint, and the merge drops the style with an error!. A design
that names a style the catalog lacks is an Error at content lint, and
ShipStyle::resolve panics on it at spawn. A style with no greebles places
no decoration.
The solve has three parts, each in its own module:
skin_surface(sections/skin_surface.rs) reads the finished plates as a QUAD GRAPH: one cell per plate, four edge slots per cell, and across each slot the plate that continues the surface and the slot of it that faces back. It finds the neighbour by the same branchesboundary_heightsreads to shape the plate, so the graph cannot disagree with the skin. The slot frame turns the same way on every face:upislink_point_upof the out face and the slots run clockwise seen from outside.solve_greeblesplaces one greeble variant on every cell: bitset domains, arc consistency over the slot links (a slot with no neighbour accepts any link type), the cell with the fewest variants next, and a weighted draw scaled by thenearterms already in range. Anearfactor of 0 is a hard ban in both directions;max_countbans a greeble everywhere once it is reached. A contradiction restarts with a derived hash up toGREEBLE_RESTARTStimes; then one solve runs without the greebles that carry a FORCING link type (one that cannot face empty space) and logs a warning. A player-built hull is a valid runtime condition, so that solve is a fallback, not a hidden authoring error. The solve also takes afitstest that trims each cell’s starting domain:solve_seated_greebleskeeps a fixture variant on a plate only whenShellShape::seatedcan fit that plate to the variant’s turned collider footprint (decor_footprint).solve_decor(sections/skin_decor.rs) turns the answer into aDecorPlacementper dressed plate: the fixture, and the quarter turn about the plate’s own out axis that points the model’s+Zat the slot holding the greeble’suplink point. It runs BEFORE the plates are meshed, and replaces each dressed plate’s shape with its seated one.
THE PLATE FITS THE PIECE. ShellShape::seat is the reach of the plateau from
the cell centre, in SEAT_STEPS (1/64 cell) steps, toward each edge. A plate
with a seat steepens every chamfer, ramp end and corner cap that cuts into
that rectangle until it clears the plateau, up to STEEPEST_SLOPE (a cell of
drop per seat step). Every fall is measured from the edge or corner it drops
to, so steepening never moves a foot or a corner height: the plate still meets
its neighbours at its feet and corners. The profile along a shared edge can
still change near its ends where a steepened fall perpendicular to it reaches
the edge, so a rim can jog at a seam beside a dressed plate.
ShellShape::seats is the exact check: the seat plane is the flattest top
plane at the centre, and every top plane must stand on or above it at the
footprint’s four corners. A piece the steepest slope cannot seat is left off
that plate by the solve, never placed over a crease; skin_report counts any
placement that does not seat as unseated, and the base hulls report none
(crates/nova_authoring/tests/base_hull_greebles.rs). A re-clad seats the
pieces of the BEFORE hull too, so the plates that keep their pieces also keep
their shape.
DETERMINISM. The seed is greeble_seed(style id), an FNV-1a hash of the id
alone, and every draw also hashes the plate’s own key (its cell and out face).
FNV-1a is hand-written because DefaultHasher is not promised to be stable
across releases of the standard library, and a ship that came back wearing
different antennae after a toolchain bump would break the promise the derived
skin exists to keep. The structure is NOT in the seed, so an edit changes the
draws only where it changes the plates and their links; the editor re-solves on
every structure change without the whole hull flickering. A re-clad keeps the
pieces on the plates it keeps and solves only the plates it lays, so a link
across the edit boundary is not checked against the kept piece.
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.
The readings above are diagnostics only: the solve reads the surface graph,
never a PlateReading. Hull edits change the distribution of plate shapes;
a fixed catalogue-wide tally does not describe the live hulls.
A plate on a run one cell wide falls on both sides, so a frame of one-cell runs
is ridges, peaks and spurs with almost no flat plate. The surface graph does not
care: every plate is a cell, and a greeble’s link points decide where it goes.
Both spawn_ship_skin and the editor’s sync_editor_skin log that histogram plus
a per-fixture tally of the plates each fixture took (decor_tally) at debug.
Integrity: damage -> disable -> destroy
The destruction stack is nova’s own, in
crates/nova_gameplay/src/integrity/, with the ship adapter in
crates/nova_ship/src/sections/integrity.rs. NovaIntegrityPlugin composes
nine generic pieces, and the ship adds its own ShipIntegrityPlugin on top:
health.rs- the hit-point store:Health,HealthApplyDamageand theHealthZeroMarkerits observer adds at zero.core.rs(IntegrityCorePlugin) - the generic disable/destroy core, plus the contact impact damage, taken from the impulse avian’s solver settled on and spent only on the approach above a universal 10 m/s safe contact speed.erosion.rs(DamageLevelPlugin) andcarve.rs(DamageMarksPlugin) - the two damage READINGS, below.spew.rs(CarveSpewPlugin) andchunk.rs(CarvedChunkPlugin) - what a carve leaves behind: dust from every carve, and a real rigid body wherever a cut actually severed material.- Ship-owned
ShipIntegrityPlugin(nova_ship, not one of the nine) - derives the section graph, handles disabled sections, rolls section health up to the ship root, and collapses a root that falls below itsStructuralCollapseThreshold. explode.rs(ExplodablePlugin) andpyre.rs(PyrePlugin) - what a destroyed body DOES and what it LOOKS like: the wreckage that drifts away, and the fireball it drifts out of. See How a body comes apart below.neutralize.rs- combat-death: firesOnNeutralizedwhen a ship stops being a threat.
Graph build: every section prototype authors local link_points with an id,
position, and outward unit normal (and a skin mode the graph ignores; see
The derived skin). When avian links a collider to its body
(ColliderOf), ShipIntegrityPlugin transforms those points into ship-root
space. Coincident points with opposed normals become symmetric ConnectedTo
neighbor edges. IDs are for diagnostics and UI, not compatibility. A malformed,
ambiguous, or disconnected graph is rejected as a whole; collider contact and
center distance never create fallback edges. SpaceshipRootMarker requires
IntegrityRoot AND DamageMarks: a ship’s hits belong to the ship, not to
whatever collider stopped them, because a crater has to cross the seams between
the plates and sections it reaches. Asteroids are not in the graph at all - a
rock carries no Health and no IntegrityRoot, only DamageMarks, and its
death is decided by its own remesh (see
Scenario engine).
How a body comes apart
Nothing computes geometry when something dies. A destroyed ExplodableEntity
DETACHES: it comes off its parent, becomes a rigid body of its own carrying the
art and the collider it already had, takes an outward kick sized by how deeply
it was buried and a spin, and despawns on a timer (PIECE_LIFETIME_SECS). A death moves entities and clones
one collider handle, which is why the module has no fragment budget and no
spawn queue - there is nothing left to ration.
The FIREBALL is the other half, and it is a separate plugin because it rations
what explode does not. pyre.rs observes the same destroy marker and spawns
two hanabi instances - a core and its ejecta - at two authored sizes: a section
burns at SECTION_PYRE, a whole hull at HULK_PYRE.
A section is the same size on every ship, because a build-grid cell is. A HULL
is not, so HULK_PYRE is the look and the dying root’s IntegrityEnvelope is
the size it is drawn at: every length is multiplied by the hull’s containment
radius over the 55.2 m reference hull the look was cut on, and the flash by the square
of it, because lumens stand in for a burning surface. The shipped hulls run
from 0.88 on a skiff-sized hull to 3.52 on a carrier-sized one, so a carrier’s debris
reaches 479 m rather than ending 58 m inside its own wreck. Durations and
particle counts are NOT scaled: a bigger ship does not burn for longer, and the
count is what the frame budget was written against. A body that publishes no
envelope gets the authored death rather than no death.
Hanabi bakes a Gradient into the asset, so a baked size curve cannot be
multiplied by a per-instance property. Every size curve in pyre.rs is
therefore an expression over the particle’s own normalised age, built by
key_curve from the same keys a gradient would hold, and multiplied by one
hull_scale property written per death - the same shape the railgun wake uses.
Almost nothing about those graphs is built at the death. warm_the_pyres runs
in Update, on the first frame that has all three of a cold store, a tier that
draws particles at all, and an active camera - and it does the whole job: the
four graphs, the shared soft-dot mask, and one hidden instance of each graph.
The instance is the part that matters, because bevy_hanabi generates a shader
from a spawned INSTANCE and never from an asset, so a warm-up that only filled
the asset store left the WGSL to be generated on the collapse frame. Those four
are silent, invisible, and despawned the next frame. A tier with particles off
builds none of it and lights nothing.
Update and not a state transition, because deaths are not confined to
Playing: the main menu’s backdrop is a scenario whose whole loop is a torpedo
erasing a ship, and that backdrop raises its camera frames before its first
death. The tier is settled well before the gate opens - the settings pass
applies in PostStartup and again on change, both inside SettingsSystems,
which the warm-up orders itself after - and the cold store is what lets a tier
RAISED from the pause overlay reach the warm-up, which no OnEnter carries.
The camera is a requirement and not a preference. hanabi gives every live
instance a row in one BufferTable of draw arguments and reconciles that table
only on a frame that has a view, so four rows taken and given back with no view
in between leave it allocating a 60-byte buffer and writing 80 bytes into it: a
panic in the render schedule at boot. Warming in PostStartup is exactly that
shape, because that is before any scenario has spawned a camera.
The ALMOST is one pipeline. A hidden instance is compiled and gets its two
compute pipelines, but bevy_hanabi specializes the RENDER pipeline only for
entities a camera can see, so the first death of a run still creates one - on
the render thread, because nova_core asks for
synchronous_pipeline_compilation on every backend. Warming that would take a
VISIBLE instance, which a warm-up is not: these are hidden exactly so that
nothing draws them.
What a death still mints is instances, so this half DOES need a budget. A
compartment death is QUEUED, and the whole frame’s batch is spent at the end of
it: 6 * sqrt(condemned / 53) fires, floored at six and capped at 48, picked by
farthest-point sampling over where the deaths happened. The reference is the
53-section reference hull the look was cut on, so an ordinary frame still lights the
six it always did, a 720-cell corridor lights 23 and a whole carrier 38. The
sampling is the half that matters: deaths arrive in section-graph order, so the
first six of a collapse are six fires in one shoulder of the wreck - the chain
has to be cut from the batch and spread over it, not taken off the front. A
root’s own fireball is never queued and never dropped, because that is the one
the whole death reads as. Material has the last word -
IntegrityDestroyMarker is a shared seam that an exhausted asteroid also
raises, and rock does not burn.
Four properties follow from that, and each is load-bearing:
- Destructibility is SEMANTIC. It is
ExplodableEntityplus the destroy marker, never where aMesh3dhappens to sit. A section carries its health, its collider and the marker on a gameplay root while its art hangs offSectionRenderOfdescendants under a gltfWorldAssetRoot, so anything gated onMesh3dat the gameplay entity finds nothing on any section ever shipped. Detaching asks about neither: the children come across whatever they are, and the COLLIDER is what says a body can be one. - An
IntegrityRootis excluded.ExplodableEntitypropagates to parents, so a root carries it too - and detaching a root would take the whole structure off at once and bypass every node’s own death. The test namesIntegrityRootrather thanSpaceshipRootMarkerbecause a severed wreck and an asteroid husk are roots that are not spaceships, and because this layer stays free of anythingnova_shipowns. - The plates and greebles ride the wreck out, their colliders do not. Every direct child moves, still bolted on, which is what a piece breaking off a hull looks like and is cheaper than making each one its own body. The colliders are stripped on the way, because avian attaches every collider under a rigid body to that body: a section wears a plate per face and greebles per plate, so a wreck that kept them is one body with dozens of shapes, times every section a collapse kills in the same frame. The section’s own collider is the shape the wreck is.
- A piece is born inside the body it left, which is a dynamic body spawned
interpenetrating another - something the solver fixes by shoving hard. So it
takes the same
ChunkGracea carved rock chunk takes: kinematic and colliderless until it has drifted clear. - How far clear is measured on the piece, not on the constant. A piece has
to cross the structure standing over it, plus its own reach, plus 10 m of
daylight. The kick and the grace window are both multiplied by the square
root of that distance over 10 m (
clearance_scale), so the SLOWEST piece is exactly outside as its window runs out: a plate off the skin leaves at 20 to 50 m/s and is physical in half a second, while a section 97 m inside a capital leaves at over 100 m/s and stays a ghost for 1.7 s. The depth comes fromIntegrityEnvelope, which the layer that owns the body publishes - a hull from its live containment radius, a rock from its own.
There is NO FALLBACK for a body with no collider: it leaves nothing behind and
logs an error, and system_destruction_finale asserts that branch never runs
on shipped content. A refusal rather than a stand-in, because a stand-in looks
like SOMETHING - a body that had silently failed to come apart would be
indistinguishable from one that came apart badly.
What the frame SOUNDS and FEELS like is grouped the same way, by the STRUCTURE
rather than by the place. An impact or a destruction cue - the explosion, the
surface hit, the camera kick and the spark burst - is throttled on its
CueGroup (nova_gameplay::audio::mixing), which is the nearest RigidBody at
or above the entity the event named: a section, a plate, a health node and an
asteroid’s collider node all hang under one. A severed fragment, a rock and a
torpedo each resolve to their own body, so each is its own event, and only a cue
with nothing physical over it falls back to a 60 m world cell (SFX_AREA_CELL,
six world units). That cell used to be the whole rule, and a cell is about the
size of a small hull, so it was the same mistake in both directions: a carrier
collapse spanned two dozen cells and threw 25 trauma kicks in the frame it came
apart - the pile-up the throttle exists to stop - while two hulls dying a few
metres apart shared one death. The same collapse throws 3 kicks now.
WHERE it plays is the other half. A death is emitted from the body’s centre of mass, not from the section that happened to be raised first, because the sections a collapse raises first are the ones nearest the wound and not the ones a listener is watching let go. A HIT still plays at the contact: that is a real place on a hull the listener can see being struck. Audio and juice share one resolver, so a collapse is one explosion and one kick from the same point.
Editor placement mates the same sockets, so the editor cannot build a ship the
graph would reject. snap_placement (nova_ship::sections::link_points) poses a
part from one mate: the two sockets become coincident and their normals opposed,
which leaves only the ROLL about that axis free - the builder’s choice, alongside
which of the part’s own sockets does the mating.
That roll has a defined ZERO, and it is what makes one part usable on every
other part. Each socket carries an implied up vector, link_point_up(normal):
ship up (+Y) projected onto the socket’s plane, or forward (+Z) on the sockets
that face +-Y. It is DERIVED from the normal rather than authored, so two parts
that never met agree on it without anyone writing a second vector, and
snap_placement mates the two socket FRAMES rather than just the two normals.
Aligning normals alone leaves the roll to whichever axis a shortest-arc rotation
happened to sweep about - and for a socket facing exactly opposite the part’s
own, to an arbitrary perpendicular.
The other half is the normals themselves. An authoring tool that derives a
socket from part GEOMETRY gets whatever angle the neighbour happened to sit at,
so cardinal_axis snaps the derived normal to the nearest axis. It is
antisymmetric (cardinal_axis(-d) == -cardinal_axis(d)), so both ends of one
authored edge stay exactly opposed and no existing mate is lost. Without it a
modelled pod faced its fuselage 36 degrees off -X and anything mated onto that
socket arrived tilted by exactly that much - which is what made parts look like
they only fit the craft they were cut from.
box_link_points(size) is the general face-socket helper
(unit_cube_link_points is box_link_points(Vec3::ONE)). A part authored at its
own size mates against a part of any other size, because the sockets meet face to
face and the roll comes from the axis alone; pdc_kinetic_turret_section (and
its pdc_pierce_turret_section twin, the same gun with a different round) is the
shipped example - one compact mount that fits every hull, replacing ten per-craft
copies of the same gun.
candidate_link_point_mates is
the same pairing WITHOUT the ambiguity and connectivity gates, because a ship
under assembly is legitimately disconnected; the editor uses it to see which
sockets are taken and to refuse a placement that would leave one with two
suitors. Collider bounds enter only as the overlap refusal, under the ship lint’s
rule: interpenetration is allowed exactly where a mate says the interface is
intentional.
scripts/cut-obj-into-parts.py proposes candidates for freshly cut parts: two
parts whose bounds meet at a seam and overlap across it get one socket each at
the centre of that shared face, written into the part manifest as link_points.
A recipe part can author its own list instead (in ship space, like every other
recipe coordinate), which replaces the generated one. They are candidates for a
human to judge - shipped gameplay sockets stay hand-authored in nova_authoring.
Damage flow:
- A hit triggers
HealthApplyDamage(nova_gameplay::integrity::health); its observer subtracts the amount and addsHealthZeroMarkerat zero. The amount also bubbles upChildOf, clamped to what the section actually had left - so overkill on one section cannot kill the ship (a 1000 hit on a 100 hp section costs the root 100).apply_damagealso takes anat: Option<Vec3>and records a mark there, so a hit says WHERE as well as how much; the ram path passes the rammer’s transform. - Zero health ->
IntegrityDisabledMarker. A depleted ship section is destroyed at any graph degree. The leaf rule remains for healthy sections disabled by final structural collapse. - Destruction prunes the node from its neighbors’ lists. If surviving structure becomes disconnected, the controller-bearing component keeps ship identity and every other component becomes an inert dynamic wreck body.
aggregate_ship_healthkeeps the root’scurrentequal to the sum of its living sections, over amaxthat is PINNED - a running maximum, never re-derived from the survivors. A destroyed section despawns, so a live denominator would make the HP bar fill up as a ship is shot apart (150/1100 reading 100/100) and would make any fraction of it rebound. It is a running maximum rather than a set-once pin because a ship’s sections can land across several frames.- At or below its
StructuralCollapseThreshold(collapse_thresholdon the ship, default 0.05) the root getsStructuralCollapseMarkerand the ship starts TEARING ITSELF APART, rather than dying on the spot:cascade_structural_collapsedisables every section still standing and hands them to steps 2 and 3 above. The extremities are leaves, so they go first and burst their debris; the prune turns their neighbors into leaves, and those go on the next frames. The wreck peels from the outside in instead of vanishing - how long that takes is the remnant’s DEPTH, so a chain peels from both ends over several frames while a shallow remnant whose sections are all already leaves goes in one. Every section’s own debris burst fires either way. - The ROOT dies last, and of the same rule. Each destroyed section leaves the
sum, so step 4 walks
currentdown to zero on its own; with no structure left the recompute marks the rootHealthZeroMarkerand the ordinary chain destroys it, which is what firesOnDefeated/OnDestroyed. That is also the standalone backstop for a last section removed WITHOUT a damage bubble (a direct destroy, a detach), which nothing else would mark, and it is what threshold0.0reduces the whole rule to.
The no-progress override. Disabling a section costs it no health - only
DESTRUCTION takes it out of the root’s sum - so a remnant with no leaf never
drains. Four hulls mated in a ring each keep two neighbors, none ever becomes a
leaf, nothing is destroyed, current never falls and the root never dies: an
immortal disabled hulk. So the leaf rule is treated as a preference for the
ORDER a wreck comes apart in, not a correctness requirement. A cascade tick
that disables nothing new AND does not see the standing-section count fall is a
stall, and the most leaf-like survivor is destroyed whatever its neighbors.
Breaking one node out of a ring leaves a chain, so the ordinary cascade
resumes and the peel is kept everywhere it is possible. Progress is measured as
that count FALLING rather than against a frame budget, because the cascade’s own
gaps are irregular while a count that fell is direct evidence a section died.
A severed wreck fragment is persistent until scenario teardown. Its healthy
sections remain damageable, but SectionInactiveMarker disconnects every
controller, thruster and weapon from the lost command bus. Fragments inherit
rigid point velocity and receive a momentum-balanced kick away from the cut. Its
raw speed is the fragment’s own containment radius over two seconds, floored at
10 m/s, so two halves open a gap as wide as themselves in about two seconds
whatever they are: a carrier’s halves part at tens of meters a second where a
cockpit shard takes the floor. They are unsigned debris, not ships: no allegiance, control, defeat event,
or scenario identity.
A ship is disabled progressively, so a collapsing ship can keep shooting for a
few frames; its weapons stop as their own sections go. That also means the
unified defeat edge usually comes from neutralize.rs partway through the
peel, and the root’s later destruction fires only OnDestroyed - DefeatedMarker
is what keeps OnDefeated to exactly one.
Structural collapse is a MATERIAL test and stands apart from neutralization
(neutralize.rs), which is a CAPABILITY test: a ship can be out of the fight
with a sound hull (a derelict to board, salvage or let limp away), and a ship
can collapse while its guns still work.
The cascade a single section walks through:
flowchart TD
A[Section takes damage] --> B[Integrity drops]
B --> C{Zero health?}
C -->|No| A
C -->|Yes| F[Destroyed]
F --> G[Pruned from neighbors]
G --> H{Graph still connected?}
H -->|No| P[Detached components become wreck bodies]
F --> I[Root current re-aggregated over a pinned max]
I --> J{At or below the collapse threshold?}
J -->|Yes| K[Every standing section disabled]
K --> Q{Leaf?}
Q -->|Yes| F
Q -->|No| E[Inactive until pruning makes it a leaf]
K --> L{Nothing destroyed this tick?}
L -->|Stalled| M[Destroy the most leaf-like anyway]
M --> G
I --> N{No sections left?}
N -->|Yes| O[Ship dead]
Damage is two readings
Health decides when something dies. It cannot decide what the wreck LOOKS like, because a pool is one number for a whole body and the only geometry one number can drive is geometry that changes everywhere at once. So there are two readings taken off a hit, and neither is a look of its own.
DamageLevel(f32) (integrity/erosion.rs) - 0.0 pristine to 1.0 destroyed,
derived from the entity’s OWN Health every time health moves. Read it, never
write it. Because it is a function of health rather than an accumulator beside
it, a body at half health always looks the same amount of wrecked, a reload
restores the look for free, and a scripted destroy grades exactly like a shot.
Derived per entity and not per aggregate: a skin plate is HealthIsolated, so a
stripped plate reads as stripped while the hull under it still reads as
untouched.
DamageMarks(Vec<DamageMark>) (integrity/carve.rs) - where the hits
LANDED, each a sphere { at, radius } in the LOCAL frame of the body carrying
the list. A hit is recorded on the nearest ancestor carrying DamageMarks, never
on whatever collider it met. That is what makes a carve continuous: a ship’s
plates each derive from the same list, so two plates sharing a boundary compute
the same depression at it and a crater crosses the seam instead of stopping at
it.
What material costs
DAMAGE_PER_UNIT_VOLUME is 8.0 hit points per cubic engine world unit -
per 1000 m^3. It is an INVERSE volume, L^-3, so it is not a length that could be
re-expressed by multiplying by ten, and converting it would move the whole
damage-to-crater exchange rate; it stays engine-side beside the signed field it
prices, which is engine geometry too. It is the whole coupling between what a
weapon costs and what it looks like it did, and it is ABSOLUTE: the same round makes the same hole in a pebble and in a planetoid,
because the hole is what the round’s energy is worth. Pricing a crater against
the body it landed on is the other design, and it makes a big rock unshootable
and a small one vanish on contact.
mark_radius(amount) is therefore (amount / 8.0 * 3 / (2 * pi))^(1/3) - a
HEMISPHERE, because a hit lands ON a surface. It answers in world units, like
everything else the field is cut in: the shipped kinetic PDC round (4.0 damage)
carves 0.62 u, a 6.2 m crater.
A mark is priced by what the hit ABSORBED, never by what it asked for
(absorbed_by): the first Health at or above the hit clamps it, a node already
spent pays nothing, and a chain with no pool at all spends the whole hit in
material. Without the clamp a slug that crosses a plate would be charged for the
plate and then charged again, in full, for the hull behind it.
The merge, and why a hole follows the aim
Sustained fire has to dig ONE hole rather than two dozen dents, and that job has a SIZE - the width of the hole the last round made - which is why it is capped as an absolute length and not as a proportion of the body. The three caps below are in the field’s own engine world units, which is the frame the marks are kept in.
MARK_MIN_RADIUS0.15 (1.5 m): below this a sphere cannot reach a boundary sample of the cell it lands in, so it would cost a budget slot and change nothing. Grazing fire should crack, which is the level’s job.MERGE_REACH4.0: a ceiling expressed as a multiple of the INCOMING bite, never of the grown crater. Testing the grown radius is what let a crater’s own growth widen the area that captured the next hit, which widened it again until one crater ate the whole body.MERGE_MAX1.0 WORLD unit (10 m), converted into the body’s own frame byDamageMarks::add: “the round landed IN the hole the last one made”. This is the cap that actually binds, and it is why the hole follows the aim.MARK_BUDGET24. At the budget the SMALLEST crater is folded into its own nearest neighbour to free a slot, so nothing is dropped, paid volume is conserved, and the hit that just landed is recorded where it landed.
A blast is the same defect wearing a different hat: it asks for its pressure once
per collider it overlaps, and a hull built out of hundreds of them would grow one
crater hundreds of times. record_blast_marks sums contributions PER OWNING
BODY and cuts them as one crater, capped at the blast’s own radius.
apply_blast_damage queues that BEFORE the health triggers, so every body prices
against one pre-damage snapshot - the same contract NovaBlast already states
for its pressure pass.
What a carve leaves
CarveSpew { entity, at, radius, kind } fires whenever a mark changed a body’s
shape, in world space. kind is the weapon class that paid for the carve, and
it is what decides the look: spew.rs keys a ShardLook off it, one entry per
DamageType. Kinetic and Pierce throw 2 to 7 shards of one fixed size
(ShardLook::size, 0.12 world units - a 1.2 m chip) that live 2.5 s, and
hold identical values in two SEPARATE entries, so giving a penetrator its own
debris is editing a number rather than splitting a branch. Explosive throws nothing: a
warhead’s fireball already covers the frames the geometry changes in, the crater
is permanent evidence afterwards, and a cut that severs throws real geometry
anyway. Shards are bevy_hanabi PARTICLES, not entities: a carve aims one of a
per-material pool of emitters (grown on demand to SHARD_EMITTERS per
material, which is also the most craters one frame chips) and the GPU throws
the cone, tumbles, cools and ends them. Nothing on the CPU stands for a chip -
no rigid body, no collider, no mesh - which is what a busy fight paid for when
they were kinematic bodies; a range reads CarveShardTally for what was thrown.
An event rather than a direct spawn, so a mod that wants a puff or nothing at
all replaces the observer instead of patching the carve.
Real geometry leaves a body only where a carve actually SEVERED it, and only the
body being cut knows that. chunk.rs is what a severed piece spawns through;
CHUNK_MIN_VOLUME (1.0 cubic world unit, 1000 m^3 - a volume, so it is engine-side with the field) is the floor under which a crumb goes out as
dust instead. The asteroid is the only body that takes this path - see
Scenario engine.
The authored looks
WHICH looks a section wears is content, not engine. A section authors
DamageEffects, a list of DamageEffect, and damage_effects.rs turns each
variant into exactly one component. Nothing else translates, and no effect system
reads the list - each reads only its own component. So the authored list is the
CONTENT vocabulary, the components are the RUNTIME vocabulary, and a Rust mod
that wants a look nobody authored inserts its own component and touches neither.
| variant | component | what it does |
|---|---|---|
Cracks | DamageCracks | Fractures the section’s surface, glows through when critical, burns out cold when dead. Replaced SCORCH, a whole-body red tint that fought every authored paint scheme and said nothing about WHERE a section was failing. |
Sparks | DamageSparks | Throws sparks, faster the worse it is, past level 0.35. Removes nothing. |
Plume | DamagePlume | Guts and flickers a thruster’s exhaust past level 0.35, floored at 25 percent so it never reads as SHUT DOWN. Touches no thrust. |
Default is [Cracks] and not the empty list, so unchanged content and
third-party mods keep behaving; DamageEffects::none() is the explicit “wears
nothing”, because “I want none” and “I did not say” are different statements.
Why cracks are QUANTISED
A section does not carry its own cracked material. Its damage level snaps to one
of SECTION_CRACK_BUCKETS steps (damage_cracks.rs), and the mesh swaps to the
material shared by every section drawn from the same source material at the same
step. Nothing is ever written into a built material, so no section can crack a
neighbour that shares its gltf art.
The reason is BINNING. A draw call bins on the material, so a value per section
is a bin per section mesh: an eleven-ship wfc_ships gallery held 2,652 section
meshes in 2,652 bins of one instance each, the worst case there is for
write_binned_instance_buffers, and it cost roughly half the frame rate. It is
also why cladding was always free - owning_section stops at a SectionFixture,
so 10,936 plates kept the 32 shared materials they were painted with and batched
normally. Buckets put sections on the same footing: source materials times
buckets, whatever the fleet size, with bucket 0 the pristine step so an
undamaged fleet batches as if the effect were not there.
The registry (SectionCracksMaterials) builds a bucket the first time something
reaches it and forgets a source material the moment nothing draws from it. Both
matter, and both are aimed at a source a MOD mints per instance rather than per
prototype: eager buckets would cost it eight materials it never draws, and a
registry that never forgot would keep them after it was gone. A pristine base
fleet is one bucket.
The same shape, for the exhaust plume
A drive’s flame is quantised the same way and for the same reason
(EXHAUST_PLUME_BUCKETS, thruster_section.rs): the throttle snaps to one of 16
steps and the cone SWAPS to the material shared by every nozzle of its shape at
that step. Nothing is written into a built material here either.
Read the two together, because the plume is where the pattern earns itself.
Cracks change rarely, so a read-before-write guard nearly covers them. A guided
torpedo’s throttle genuinely moves EVERY frame, and a hundred can be in the air -
so the guard covers nothing, and only sharing does. Assets::get_mut marks an
asset modified whether or not the value moves, and a modified material is
re-extracted, re-uploaded and has its bind group rebuilt that frame.
What quantising costs is smoothness, and the shader is what makes 16 enough: one
bucket step moves the flame tip by 0.0667 local units, and thruster_exhaust.wesl
already jitters that same tip by up to wobble_amp = 0.1 every frame.
The rule the vocabulary is kept honest by: NO SHIP SECTION LOSES GEOMETRY.
Every effect here is a material or a particle, and the only thing that changes a
ship’s shape is a whole PIECE leaving - a plate shot off, a section destroyed. A
Carve effect that cut a real crater out of authored art was built and then
removed: reading a solid out of a drawn mesh costs 6-15 ms per mesh, a ship’s
marks belong to its root, so one round anywhere on a hull turned every mesh under
it into a solid in one frame - 325 meshes, 2.0 seconds. A rock still carves,
because a rock’s solid is analytic and its collider IS its mesh.
Typed damage (crates/nova_gameplay/src/damage.rs)
Weapon damage is authored, not emergent from bullet physics, and it is ONE
number: there is no resistance table and no per-section multiplier anywhere in
the damage path. A projectile carries
ProjectileDamage { amount, power, layers, kind } with a DamageType:
Kinetic, Pierce, or Explosive. apply_damage is the single point at which
any weapon enters the health store, and it is a plain HealthApplyDamage
trigger - nothing between the weapon and on_damage reinterprets the number.
A type is a way of TRAVELLING, not a multiplier. That was the point of dropping
the table: a round visibly crossing three sections is legible from the cockpit,
a 1.5x is not. SectionClass survives the table as the interface’s section
LABEL (nova_interface reads it for codes, icons and descriptions); nothing in the
damage path branches on it.
Turret bullets are given a near-zero physical mass (NEUTRALIZED_BULLET_MASS)
so the impact path’s mass-times-velocity damage
(on_impact_collision_deal_damage, integrity/core.rs) is negligible and the
authored amount is the only weapon damage. Torpedoes detonate a NovaBlast.
damage.rs computes linear falloff from each collider’s world centre. For a
target it then walks the centre ray through closer live ship sections: a
survivor stops pressure, while a destroyed section transmits 65 percent. All
blasts collected in one fixed tick read one pre-damage health snapshot. Health is
charged per COLLIDER; the crater is cut once per BODY (see
Damage is two readings).
The torpedo fuze
CONTACT_FUZE is 3.0 units to the target’s SKIN, not to its centre of mass. A
torpedo holding a locked ENTITY projects its position onto the colliders in that
body’s own RigidBodyColliders list. The projection is solid, so a nose already
inside the hull reads zero, and reading only the locked body’s list means a
torpedo threading a formation cannot fuze on the wrong ship. The three-unit
margin clears the torpedo’s own body and both bodies’ motion through the next
fixed step. contact_reach(speed, dt) = CONTACT_FUZE.max(speed * dt) widens it
further for a fast torpedo.
The old fuze was half the blast radius measured to the centre of mass. It had three consequences and no upside: a torpedo always stood off exactly half a blast radius and so always delivered exactly half its rated pressure; against a rock the crater was cut in vacuum beside the surface, because a rock’s centre is buried under twelve units of solid; and nothing in the game had a contact fuze at all.
That fallback survives for the one case with nothing to touch: a torpedo with a
target POSITION but no entity (a scripted launch, or one whose target died in
flight) still fuzes at blast.radius * 0.5. A torpedo launched with no lock
never receives a target position at all, so it cannot detonate - it flies its
lifetime, deals a contact ding and is deleted, and the bay still spends the
round.
Note weave_fade is measured off the BLAST RADIUS and not off the fuze: full
weave beyond three blast radii, linearly to zero at half a blast radius. The
terminal sprint has to start where the corkscrew stops helping, out at
point-defense range, not where the warhead finally fires.
Closing speed
Both BULLET types are speed-driven, and the term is computed at the hit, not at
the muzzle: closing_speed(round_velocity, target_velocity) projects the same
relative velocity on_impact_collision_deal_damage uses onto the round’s own
line of flight (projecting onto the line BETWEEN the bodies is unusable - at
contact they are touching, so that direction is noise). Both curves are the
speed ratio against REFERENCE_CLOSING_SPEED, which is 100 engine world units
per second because it is compared against avian velocities - the same speed the
shipped PDC authors as muzzle_speed: 1000 m/s - clamped:
kinetic_damage_multiplierscales what a hit DEALS, clamped to[0.25, 2.0];pierce_power_multiplierscales how far the round GETS - it divides what a layer costs - clamped to[0.5, 3.0].
Linear, not the ram model’s own curve: impact_damage is impulse plus absorbed
energy, and at bullet speeds the quadratic energy half reads ~3.9x at twice the
reference, which would turn a ~400 DPS PDC into ~1600. Both read exactly 1.0 at
the reference, so authored bullet_damage values keep the feel they were tuned
for. Speed scaling is deliberately NOT in apply_damage: a ram already carries
its velocity in the amount, and a blast has no line of flight.
The travel rule
pierce_remainder (damage.rs) is the whole rule, one branch per type;
spend_piercing_damage deals hit_bite through apply_damage and then calls
it.
- KINETIC spends its DAMAGE.
amountdoubles as the budget: a hit that fails to destroy the target has by definition put the whole bite into it, so the round dies; a hit that destroys it costs only the health that was there, priced back through the speed curve that scaled the bite, and the rest flies on. A slug can never deal more in total than it was fired with. - PIERCE spends POWER, never damage.
amountis flat - the same bite into every layer, no speed term and no decay with depth. Crossing a layer costs that layer’sHealth.maxdivided bypierce_power_multiplier. MAX, not remaining, for two reasons: light plating stays nearly free while a heavy block is expensive (the spaced-armour intuition), and softening a section with other fire cannot open a cheaper hole through it. A rake’s TOTAL damage therefore exceeds what it was fired with, which is intended.PIERCE_BASE_POWER(300 hp of thickness) is the budget, and power is the only bound: cheap plating lets one round chain through many layers. A layer that costs no power stops it.
A target with no Health on the hit collider (an asteroid, a planetoid, a pool
that lives on an ancestor) has no thickness to price and nothing provably
destroyed, so it is a wall to both types at any speed. Nothing in the rule knows
what it hit, so destructible cover needs no special case. Torpedoes do not use
it - they detonate on a contact fuze.
The carve reads the same absent pool and draws the OPPOSITE conclusion, and the
pair is easy to misremember. absorbed_by walks the same ChildOf chain: no
pool anywhere up it means the whole hit is spent in MATERIAL. That is the
asteroid rule, not a fallback - a rock’s remaining solid is its durability, and
clamping against a pool it does not have would stop rocks carving at all.
One avian trap the hit callsite has to handle: CollisionStart is raised once
per EVENT-ENABLED collider, so a contact with events on both sides arrives
twice with collider1/collider2 swapped. An asymmetric rule must act on one
ordering only (resolve_bullet_hit keys on the round being named first;
on_nova_blast_collision on the blast being body1), or it pays out twice
per contact. A symmetric rule - ram damage - wants both.
Ammo
SectionAmmo(sections/ammo.rs): optional magazine on a weapon section. Absent = unlimited fire; the turret/torpedo/railgun config’sammunition: AmmoCapacity::Limited(n)opts in (Unlimitedis the default). Unlimited fire is authored on the GUN, never granted ship-wide: a config leftUnlimited(seeSectionConfig::without_magazine) is the authoring route, and the armedammo infinitecommand /SetInfiniteAmmoaction are the runtime ones (they stripSectionAmmoandSectionReload, recording them inSuspendedSectionAmmoto restore full on the way back off).SectionReload(sections/ammo.rs): optional idle batch reload on a magazine, from the turret/torpedo/railgun config’sreload: ReloadConfig::Batch(SectionReloadConfig { delay, amount })(Disabledis the default). Seeded with the weapon’s ammunition item id (PdcRoundfor a turret regardless of mount damage type,RailSlugfor a railgun,Torpedofor a torpedo bay regardless of bay type). Every successful shot resets progress; every completed quiet delay movesmin(missing, amount, parent ShipInventory's count of that item)from the parent ship’sShipInventoryinto the magazine - rounds and items change together, so the pair is conserved, and with none of the item in stock the delay does not advance. A full magazine reads no inventory, so the ship editor’s preview sections, full under a view entity, tick safely. A section below capacity must have a parent carrying aShipInventory(every ship root requires one) ortick_section_reloadpanics. Fire runs beforetick_section_reloadin FixedUpdate so a shot wins an exact completion tick. Unlimited weapons never reload. The HUD readsprogress()andincoming_rounds()to pulse only the next batch, andis_reloading()folds the reserve check in so a dry ship shows no reload.LoadedBullet(sections/turret_section/mod.rs): the turret’s loaded-round slot (damage type + amount), seeded from the config. Fired bullets and the HUD ammo readout colors read this slot, so swapping ammo types is one component write.DefaultProjectileRender(sections/turret_section/render.rs): the built-in round art, ONE mesh + material perDamageType, built inFromWorld. The render observer reads the round’s ownProjectileDamage.kindand hands out clones, because a turret’s authoredprojectile_render_meshis per-TURRET while the fired type comes fromLoadedBulletat runtime. Every shipped turret leaves that fieldNone, so this IS the shipped path at 100 rounds/s per muzzle: it must never allocate per shot, anddefault_projectile_render_allocates_no_assets_per_shotpins that. Its meshes come fromsections::nose_cone_mesh(a cylinder and a cone, merged), which the torpedo warhead’sDefaultTorpedoRendershares. The warhead colours ITS copy of that mesh with aStandardMaterialheld per TINT, so a salvo of one ordnance type is one material however many tubes fired it - and so its crack buckets, which key on the source, are shared by the salvo too. Note a cracked material is anExtendedMaterialrather than aStandardMaterial, which is whySectionCracksholds a STRONG handle to itssource: once the mesh’sMeshMaterial3d<StandardMaterial>has been swapped away that handle is the only thing a later bucket can be built from, and a source that cannot be dropped cannot have itsAssetIdreissued to something else.
Find it in the code
- Section kinds and base config:
SectionKind,BaseSectionConfig-crates/nova_ship/src/sections/base_section.rs. - Spawn path:
insert_spaceship_sections-crates/nova_scenario/src/objects/spaceship.rs. - Design sections, lost-section codes and rebuild:
ShipDesignSections,SectionRebuildRequest,rebuild_anchored-crates/nova_ship/src/sections/design_sections.rs;plan_section_rebuild-crates/nova_gameplay/src/inventory.rs; the pane:crates/nova_interface/src/ship/rebuild.rs; proof:examples/systems/system_station_rebuild.rs. - Section swap:
section_swap_family,plan_section_swap,swap_build_fault,SectionSwapRequest-crates/nova_ship/src/sections/design_sections.rs;ShipMeasure-crates/nova_ship/src/sections/ship_measure.rs;swap_design_section-crates/nova_scenario/src/objects/spaceship.rs; the pane:crates/nova_interface/src/ship/catalogue.rsandrefit.rs; proof:examples/systems/system_station_swap.rs. - Section add:
free_link_points,section_add_pose,section_add_clear,section_add_fit,add_build_fault,added_section_binds,added_section_id,plan_section_add,SectionAddRequest-crates/nova_ship/src/sections/section_add.rs;link_point_pose,nearest_link_point-crates/nova_ship/src/sections/link_points.rs;add_design_section-crates/nova_scenario/src/objects/spaceship.rs; the pane:crates/nova_interface/src/ship/catalogue.rs,ghost.rsandrefit.rs; proof:examples/systems/system_station_add.rs. - Section delete:
remove_build_fault,SectionRemoveRequest-crates/nova_ship/src/sections/design_sections.rs;reclad_round_removalcrates/nova_ship/src/sections/shell_skin.rs;remove_design_section-crates/nova_scenario/src/objects/spaceship.rs; the pane:crates/nova_interface/src/ship/delete.rs; proof: its tests andexamples/playable/station_docked_sandbox.rs.
- Hull buy:
ParkedHullType,hull_price_cr,plan_hull_buy,bought_hull_pose,HullBuyRequest-crates/nova_ship/src/sections/hull_buy.rs; the catalogue,buy_station_hullandredock_bought_hull-crates/nova_scenario/src/objects/station_hulls.rs; the generator:crates/nova_world_base/src/station_hulls.rs; the Station pane:crates/nova_interface/src/station/shipyard.rs; proof:examples/systems/system_station_buy.rs. - Escort hire:
escort_id,escort_price_cr,plan_escort_hire,FLEET_SIDES,EscortHireRequest-crates/nova_ship/src/sections/escort_hire.rs;hire_offer_offsets-crates/nova_ship/src/sections/hull_buy.rs;hire_offer_place-crates/nova_scenario/src/objects/parked_hulls.rs;AIFollow-crates/nova_ship/src/input/ai/follow.rs; the spawn,freeze_fleetandthaw_fleet_escort-crates/nova_scenario/src/objects/fleet.rs; the pane:crates/nova_interface/src/station/escorts.rs; proof:examples/systems/system_station_hire.rs. - Station buy:
station_price_cr,STATION_OWNERSHIP_FACTOR,plan_station_buy,StationSaleType,owned_station,OWNED_STATION_NAME-crates/nova_ship/src/sections/station_buy.rs;OwnedStationMarker-crates/nova_gameplay/src/markers.rs;FrozenStation.owned-crates/nova_scenario/src/objects/station.rs; the pane:crates/nova_interface/src/station/station_buy.rs; proof:examples/systems/system_station_own.rs. - Hull strip:
plan_hull_strip,plan_section_yield,strip_duration_secs,BuildRecipe::strip_parts_at-crates/nova_gameplay/src/inventory.rs;HullStripJob,next_strip_section,SectionStripRequest,strip_section-crates/nova_ship/src/sections/section_strip.rs; the strip block and the timer:crates/nova_interface/src/inventory/strip/; proof:examples/systems/system_station_strip.rs. - Integrity core:
NovaIntegrityPlugin-crates/nova_gameplay/src/integrity/mod.rs; graph, sever and collapse:ShipIntegrityPlugin-crates/nova_ship/src/sections/integrity.rs. - Typed damage and the travel rule:
DamageType,apply_damage,pierce_remainder-crates/nova_gameplay/src/damage.rs. - The two damage readings:
DamageLevel-crates/nova_gameplay/src/integrity/erosion.rs;DamageMarks,DAMAGE_PER_UNIT_VOLUME,mark_radius,record_blast_marks-crates/nova_gameplay/src/integrity/carve.rs. - Carve leftovers:
CarveSpew-crates/nova_gameplay/src/integrity/spew.rs;spawn_carved_chunk,CHUNK_MIN_VOLUME-crates/nova_gameplay/src/integrity/chunk.rs. - How a body comes apart:
detach_destroyed_body,DetachedPieceMarker-crates/nova_gameplay/src/integrity/explode.rs; the fireball over it,SECTION_PYRE,HULK_PYRE,frame_cap,warm_the_pyres-crates/nova_gameplay/src/integrity/pyre.rs; what it sounds and feels like,CueGroup,cue_body,body_middle-crates/nova_gameplay/src/audio/mixing.rs. - Authored damage looks:
DamageEffect,fit_damage_effects-crates/nova_ship/src/sections/damage_effects.rs, with one module per look indamage_cracks.rs,damage_sparks.rsanddamage_plume.rs. - The spinal lance:
RailgunSectionConfig,RailgunCharge,charge_and_fire_railgun-crates/nova_ship/src/sections/railgun_section/; its sight:BoreSightPlugin-crates/nova_hud/src/bore_sight.rs; its AI commit envelope:crates/nova_ship/src/input/ai/railgun.rs. - Derived skin and styles:
ShipSkinPlugin-crates/nova_ship/src/sections/shell_skin.rs;ShipStyleConfig-crates/nova_ship/src/sections/skin_style.rs; the shed and its cap,shed_dead_fixtures,SHED_TICK_CAP-crates/nova_ship/src/sections/fixture.rs. - API detail:
cargo doc --open -p nova_ship(integrity and damage:-p nova_gameplay).
Scenario / modding system
How-to companions: Create your first scenario to write one in RON with existing primitives, or Extend the scenario engine to add new event, filter, action, or object kinds in Rust. The exhaustive construct-by-construct catalog for authors is the modding reference; this page is the engine’s internals.
crates/nova_scenario is the data-driven scenario engine, the layer for
missions, objectives, and reactive world behavior. A scenario is a list of
event handlers; each pairs an event with filters (all must pass) and actions
(run in order). It builds on GameEventsPlugin/EventWorld from
nova_events; nova_scenario provides NovaEventWorld and the enums below.
Three surfaces write that list: a RON file, a Rust builder under
nova_authoring, and the editor’s EVENTS mode, which lifts a handler into nodes
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 field’s row says about it, through bevy/reflect_documentation.
EVENTS draws those nodes as interlocking blocks, and the canvas is a view: it
holds no second document (crates/nova_editor/src/ui/canvas/). A handler is a
hat, an action a stack block, a sequence or cinematic a C-block whose steps are
C-blocks of their own, a filter a hexagonal boolean, and a value a round
reporter. The rules of what fits where live in event/blocks.rs: refusal
answers every drop, and the palette, the slot lighting and the drop itself all
read it. Only a handler stands alone, because a block outside a handler has no
lowering. A handler’s optional position is the one thing the canvas adds to
the file; the runtime ignores it, and absence means auto-layout.
A condition with an empty socket has no lowering either. It raises an error in Problems, which blocks Play, Save and Package until the author fills or removes it, because a save would otherwise drop the half-built block without a word.
Scenario structure
ScenarioConfig-id,name,description,cubemap(skybox),skybox_brightness(lux, defaults toDEFAULT_SKYBOX_BRIGHTNESS),role,world(which world the scenario runs on, see below),events.ScenarioEventConfig- one handler:label(optional, what the handler is for in the author’s words - the editor’s tree reads it beside the trigger),name: EventConfig,once,filters,actions.onceretires the handler the first time its filters PASS (not the first time its event fires): the loader-spawned entity is despawned, andScenarioEventConfig::build_handleris the single place a config becomes a runtime handler, so the loader and every headless rig honour the same fields.GameScenarios(HashMap<ScenarioId, ScenarioConfig>)- all known scenarios, populated bynova_assets(ready atGameAssetsStates::Loaded).CampaignConfig-id,name,scenarios(ordered member scenario ids, each a launchable chapter - naming a non-Chapterscenario is a lint Error); a first-class content kind (Campaign((..))).GameCampaigns(HashMap<CampaignId, CampaignConfig>)- all known campaigns, the ordered campaign->scenario mapping the Scenarios picker groups/launches by, populated bynova_assetsalongsideGameScenarios.CurrentScenario(Option<ScenarioConfig>)- the loaded scenario, if any. Thescenario_is_liverun condition gates the ship input/section sets on it.
Which world a scenario runs on
ScenarioConfig::world is a ScenarioWorldType, one field independent of
role:
Void(the default, written as noworld:line): the authored objects are the whole scene. Every shipped chapter, backdrop and lesson.Generated((seed: Option<u32>, bounds: Option<(position, radius)>)): the base generator streams sectors around the ONE player ship the scenario spawns inOnStart(lint refuses zero or two).seed: Nonetakes the seed the player typed on New Game, carried bynova_scenario::OpenWorldSessionwith the start role the player picked and theStartingStationNew Game chose (the nearest living Ally station to the origin; a seed without one is refused), which only such a scenario may spawn throughSpawnStartRole(lint Error elsewhere) at that station’s berth standoff, wherenova_world_basedocks it on a fresh start; it also authors anOnResumehandler spawning that one player ship and the lights (lint Error without one; one player exactly), because a Load firesOnResumeinstead ofOnStart. The open world builds one spawn list and hangs it on both; only New Game and Load may start such a scenario, so the picker renders no row for it, a campaign may not list it, and--scenario <id>refuses it at boot.seed: Some(n)is a fixed seed: the scenario is a bounded world the picker lists and launches with no session, and it is session-only on every build: no world folder, noWorldSaveSession, so the pause Retry reloads the scenario and Load last save is never offered. Only a chapter may takeseed: None; a backdrop or a lesson range with it is a lint Error, since the menu and the handbook start them with no session.
bounds: Some((position, radius)) is a sphere in metres the player is meant
to stay in. configure_scenario_shape (loader/wake.rs) queues it as ONE
scenario area through ScenarioAreaConfig::bounds and the same queue path
CreateScenarioArea uses, under the id BOUNDS_AREA_ID ("bounds"), before
OnStart fires, so the player ship lands inside it and leaving it is an
ordinary OnExit with id: "bounds". The outcome is authored, never
hard-coded: the engine ends nothing and clamps nothing, and the world keeps
streaming sectors past the edge. Lint: the radius is at most
nova_world_base::OPEN_WORLD_BOUNDS_RADIUS_MAX (the active window’s half
edge, Error); a handler spawning its own bounds object doubles the id
(Error); a bounds no OnExit handler or Sequence gate filters on is a
sensor with no outcome (Warn). A Void scenario cannot carry bounds
structurally. The example mod’s example_bounds is the shipped instance, and
system_world_bounds the asserted range.
nova_world_base::sync_open_world arms WorldConfig from this block and the
player ship, and disarms it under a Void scenario or none. The base bundle’s
new_game_scenario must name a scenario whose world is
Generated((seed: None)); the content lint checks it, and
nova_menu::start_new_game_scenario checks it again on the way into Playing:
a start that takes no session, a start or pick nobody registered, and a
session-seeded scenario reached with no OpenWorldSession each log a
NewGameStartFault and end the app with an error exit. There is no fallback
to another scenario.
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 (each wearing itsScenarioHandlerIndex), firesOnStart. The camera opens on the authored PLAYER spawn, in the chase rig’s own pose for that hull’s envelope (chase_camera_opening_pose), so the first frame is already the shot the live rig will hold; a scenario with no player falls back to 100 m up and 200 m back from the origin. No engine light: a scene is lit by theLightobjects it authors, and one that authors none renders black.ScenarioLoaded- fired after a load; carriesscenario_id,handler_count,object_countfor smoke-test assertions.ResumedScenario(SavedScenario)- a world Load’s saved scenario state, inserted bynova_world_base::resume_worldbefore the load. The load whose id it names takes it:open_scenario_run(loader/lifecycle.rs) skips the handlersspent_handlerslists (theoncehandlers that fired in the saved run, recorded bySpentHandlersfrom the engine’sHandlerRetiredtrigger), callsNovaEventWorld::restore(clock, variables, objectives, story, readouts, timers rebased from their REMAINING seconds), mirrors the objectives intoGameObjectives, seedsSpentHandlersandGeneratedMarks, and firesOnResumeinstead ofOnStart. A resumed id that does not match the loading scenario is dropped with an error log andOnStartfires. A save naming a scenario that is not inGameScenariosis refused at Load (WorldRefusal::Scenario). The snapshot that writes the state waits (the bounded settle) while aSequenceor cinematic is running, so a save is never mid-beat; thetest_support::open_scenario_runrig helper opens a fresh or resumed run headlessly.UnloadScenario- tears everything down and clearsCurrentScenario.ScenarioScopedMarker- any entity carrying it is despawned (recursively) on load/unload. Teardown also runsNovaEventWorld::clear()and clears all HUD hint emphasis.
Cleanup contract: every entity spawned while a scenario is live must (1) carry
ScenarioScopedMarker (all scenario objects do), (2) carry a lifetime
component - register_scenario_scoping tags every transient with
ScenarioScopedMarker the moment it declares one (TempEntity for countdown
transients, SfxAudioMarker for SFX one-shots), so projectiles, blasts,
debris, blast cosmetics and still-playing sounds all die with their scenario,
(3) be a child of a scoped entity, or
(4) be torn down by a Remove observer (the HUDs on PlayerSpaceshipMarker).
Anything else leaks.
A TempEntity does NOT clean itself up reliably: its countdown runs on
Time<Virtual>, which the pause menu and the outcome overlay STOP. A torpedo
blast that fuzes on the frame the player dies therefore outlives the whole
Defeat overlay and survives Retry, arriving in the reloaded scenario with its
damage intact. Scoping ON the lifetime component, rather than trusting the
lifetime to run out, is what closes that.
The load warms every hull the scenario can spawn
A section’s glTF is resolved by the render observer that builds its mesh child,
so the FIRST ship wearing a hull paid for that hull’s art. A hull no OnStart
event spawns is therefore cold when its beat arrives: both of menu_duel’s
corvettes appeared in placeholder art and dressed themselves a moment later.
preload::scenario_render_meshes walks the loaded config for SpawnScenarioObject
and ScatterObjects actions, resolves each ship’s ShipDesignSource and every
section’s SectionSource against the two catalogs, and collects the render
meshes. That walk is possible at all because a spawn action carries its object’s
WHOLE config inline rather than an id looked up at spawn time, so what a
scenario can spawn is readable before it spawns anything.
Three parts make it work:
AssetRef::resolveis idempotent, so the spawn site is unchanged - it asks theAssetServerfor the same path and gets back a handle that is already warm.ScenarioPreloadHOLDS the handles for the scenario’s lifetime. Without a strong handle bevy frees the mesh again long before the mid-mission spawn.- The load WAITS:
scenario_has_settledand the LOADING panel both hold while the warm-up is pending. The budget is a NO-PROGRESS one (PRELOAD_STALL_SECS, 10 s): every handle that settles buys the whole budget again, so a big catalog on a cold disk takes as long as it takes, and what the budget catches is an asset source that has gone silent. A handle that fails, or a load that stops moving, fails the load CLOSED - see below.
Ships are the only object kind involved. A beacon builds primitives, an
asteroid meshes itself on a worker, and a light and an anchor have no mesh at
all. The warm-up is also registered only when
NovaScenarioPlugin::render is set: a headless rig builds no mesh children, so
there is nothing to warm and nothing to wait for.
The load is atomic (loader/gate.rs)
A scenario used to start the moment its config landed: its objects arrived over the following frames, its art after that, and the player had the helm through all of it. Rocks popped in around a ship already flying, mission time had already run, and a collision could happen against a body still spawning.
ScenarioLoadGate closes that. From ScenarioLoaded until the frame the
scenario is built it is Loading, and while it is:
- the simulation is HELD -
Clocks::hold(FreezeOwner::ScenarioLoad)pausesTime<Virtual>andTime<Physics>, so the scenario clock, physics, timers and everyTime-driven system stand still; - gameplay input and both camera rigs are gated off (
scenario_play_is_freeonSpaceshipInputSystems,SpaceshipSectionSystems,NovaCameraSystemsandWASDCameraSystems::Sync) - holding the clocks alone is not enough, because a chase camera reads the mouse and a free-fly camera reads held keys on REAL time; Time<Real>keeps running, so the LOADING panel animates.
The release is scenario_has_settled PIPED into
release_when_scenario_is_built, which is the same condition the panel’s own
dismissal reads: the panel cannot come down over a held world, and the world
cannot start behind a panel. The frame that releases is the last frozen one -
its delta was taken while the clocks were held - so the first frame the player
is given is a complete scene at scenario time zero.
A load that FAILS never releases. An explicitly failed handle, or a load that
stops making progress, calls fail_scenario_load: the gate goes to Failed,
the hold STAYS, and the paths are reported through ScenarioStartFailure -
the same FAILED TO START overlay the content gate uses, which offers Main
Menu and nothing else. Continuing with placeholder art would hand the player a
scene that is not the one the author wrote, with no way to know it. Leaving
through UnloadScenario is the one path out of Failed, and it drops the
hold with the scenario that took it.
A load the content GATE refuses never takes a hold at all - on_load_scenario
lints the config in hand (start_errors) and returns before ScenarioLoaded
fires. A refusal ENDS the scenario it was asked to replace: the teardown runs
and CurrentScenario is cleared BEFORE the report is published, the LOADING
panel comes down on the spot, and the report is drawn on a 2D camera of its
own, with the cursor freed for the one button on it. Nothing is left to
simulate behind the modal, which is the point - the report is a dead end, and
the scenario it interrupted is not a fallback the player can be handed back.
The campaign case is a chapter whose next chapter a mod removed.
The vocabulary, and who documents it
Three closed enums are the whole authored language, one dispatch match each:
| Enum | File | Trait it dispatches to | Creator reference |
|---|---|---|---|
EventConfig | events.rs | EventHandler<NovaEventWorld> (via From) | /create/events/ |
EventFilterConfig | filters.rs | EventFilter<NovaEventWorld> | /create/filters/ |
EventActionConfig | actions/mod.rs | EventAction<NovaEventWorld> | /create/actions/ |
The construct-by-construct catalog is /create/, not this page. Every
event’s exact firing condition, every filter field, every action’s RON and
defaults are the authored CONTRACT and have to be exact; a second copy here
would be nobody’s job to update, and a reader would have no way to tell which
one was true. This chapter covers what the enums do not show.
A config is held together by STRINGS, and to the type system every one of them
is a String: SetAllegiance names a ship, TimerCancel names a timer,
NextScenario names a scenario. Names (names.rs) puts the difference on the
field as a reflect attribute - #[reflect(@Names::Object)] and its
NewObject / Variable / Timer / Objective / Scenario / Order /
Section siblings - so
anything walking a config by reflection can offer the ids in scope and mark one
that resolves against nothing. The editor’s inspector is the reader that exists;
a surface keeping its own list of which field names what goes stale the day an
action is added, which is the failure the attribute removes. A new string field
that refers to something declares what, or it is a blank box.
Generated bodies: reached by typed id, never by name
A streamed body - a rock, a planetoid, a derelict the world generator placed -
is scenario-SCOPED but deliberately not ScenarioAddressableMarker:
nova_world reads that marker’s absence as “mine to adopt and freeze”
(frozen.rs::adopt_moving_bodies, and the save refuses an addressable
persistent body as UnownedBody). So no authored id reaches one, and the
scoped_entities family never returns one. Instead every streamed body wears
GeneratedBodyId (nova_events::generated) beside its EntityId, at spawn
and at thaw: the cell it was generated in, the generator’s stem and an index.
Display spells it as the EntityId string the body always had
(sector_n2_0_0_background_rock_3) and FromStr reads exactly that back,
so a payload string that names a generated body is the typed id and nothing
written by hand is.
A hired escort wears a GeneratedBodyId too (escort_id: the station’s id
with the stem <stem>_<index>_escort and the roster slot as its index), but it
is not a cell’s body: PlayerFleetMarker keeps it out of adopt_moving_bodies
and the freeze, freeze_fleet (nova_scenario/src/objects/fleet.rs) writes
it into SavedPlayer.fleet, and the player’s spawn thaws it beside the
player, following it. The save refuses a fleet escort on a slot outside
FLEET_SIDES or on a slot another escort holds. A neutralized escort loses
the marker and its AIFollow, so the next Adopt gives the wreck to the cell
it stands in.
Three consequences, each with its owner:
- Lint refuses the literal. A
Names::ObjectorNames::NewObjectfield, or anEntityfilter id, that parses as aGeneratedBodyIdis an Error naming the field (lint/scenario.rs::check_object_names). Such a body exists only at runtime, on a seed. - A handler binds it from the event.
ObjectiveMarkerAttachandObjectiveMarkerDetachtaketarget_id: "$id"or"$other_id", read from the firing event’s payload (actions/mod.rs::bind_object_reference); the bound string parses as aGeneratedBodyIdand resolves throughgenerated_entity, the one lookup for streamed bodies. No other reference field reads a binding, and lint refuses a$spelling anywhere else. - The mark outlives the body.
GeneratedMarks(marks.rs) remembers each mark by typed id;reapply_generated_marksputs it back onAdded<GeneratedBodyId>, which a thaw is. The map clears with the scenario. The world save lists the map (SavedScenario::marks) and a resumed load puts it back before the first cell streams in.
system_generated_marks is the proof: the example mod’s example_survey
chapter marks a rock through $other_id, a one-cell jump retires its sector,
and the thawed body wears the same id and the same chip with the once
handler still at one firing.
Events carry identity, not payload-by-position: entities wear
EntityId(String) and EntityTypeName(String), and every PAIR event has the
same filter shape - a subject id plus an other_id / other_type_name.
Which entity is the subject is per-event (area against body, well against ship,
target against locker, the hull docked with against the hull that asked), which
is why the filter is one struct rather than one
per event. Lock, orbit, docking and player STOP/GOTO completion events are
one-shot EDGES with no hidden timer behind them: a target switch queues end-old
then start-new, a successful GOTO reports only after physical settle, and a
scenario that needs a continuous hold composes the edges with a keyed timer.
Filters read and never mutate; they take &NovaEventWorld and the fired
GameEventInfo and return a bool, and every filter on a handler must pass.
Actions take &mut NovaEventWorld and run in order. Neither touches the Bevy
World directly - see the seam below.
flowchart LR Event["Event fires"] --> Filters["Filters gate"] Filters -->|all pass| Actions["Actions run"] Filters -->|any fail| Stop["No-op"] Actions --> Vars["Mutate variables"] Actions --> World["Mutate event world"] Actions --> Objects["Spawn / affect objects"] Filters -->|"all pass + once"| Retire["Handler despawns"]
A once handler retires on the PASS edge, so a refused event leaves it live
and a beat waiting on a condition keeps every later chance at it. Retirement
is a despawn, which maintain_handler_index turns into an index removal
before the next dispatch; the dispatcher also holds a pass-local spent set,
because one drain pass walks the whole queue against a single index snapshot
and two queued events of the same name would otherwise reach the same handler
twice.
What an action does that its RON cannot show
Most actions are a straight write into NovaEventWorld. Six are not, and the
difference is engine behaviour rather than authored syntax:
Outcomeis not just an overlay. Setting one puts the app intoPauseStates::Pausedfor as long as it is set, so physics, AI, weapons and timers stop behind the banner while the overlay’s own buttons stay live. Scenario teardown clears it, which also releases the pause.SetCamerahas to WIN every frame. It dropsWASDCameraControllerand pins aScriptedCameraPosethat is re-enforced inCameraAuthoritySystems::Override, because both camera controllers keep writing the cameraTransformotherwise - the same swap the player-ship-spawn observer does.SetCameraAnchoris the same override, SOLVED every frame instead of once:ScriptedCameraAnchornames an entity, an offset in its local or the world frame, and what to look at, andtrack_scripted_camera_anchorrebuilds the pose from wherever that entity now is.ScriptedCameraTransformis the one thingOverrideenforces, so the two pose kinds compose - swapping one for the other never drops the camera. Losing the anchor entity releases the override rather than freezing the shot, and alook_atentity that dies falls back to the anchor. It is camera authority ONLY: it issues no helm order and does not change input authority.ReleaseCameraremoves both pose kinds. There is nothing to restore because the scenario camera IS the player’s chase camera - the chase sync keeps writing inCameraAuthoritySystems::Solvethe whole time the override is up, so dropping the override hands the view straight back.SuspendPlayerControlsets the sharedPlayerControlSuspendedgate and immediately clears held burn, RCS, rotation, radar, combat stance, and weapon intent. Player input observers also read that gate because observers bypass system-set run conditions.ResumePlayerControllowers it. Both are idempotent, and scenario teardown always resumes control. The flight context is lowered while suspended;Alwaysremains live for pause, menu, and an explicitly always-live cinematic-skip binding. Physics, timers, AI, scripted orders, and the cinematic do not use the player gate.SetSkyboxinstalls DEFERRED. The skybox setup observer reads the image immediately and would panic on a handle that has not loaded, so the action only tags the scenario camera withPendingSkyboxSwapandapply_pending_skybox_swapsinserts the realSkyboxConfigonce the image is in. A failed load warns and leaves the sky alone.NextScenariowithlinger: truedoes not switch on its own: it parks the request until something clears the flag. That something is the scenario-advance input or an outcome-overlay button, which is how Continue and Retry ride a queued switch.
HintEmphasisSet is worth one line for the same reason: the keybind dock draws
verbs the ship cannot use dim, so an emphasis on an unavailable verb pulses its
chip in the dim band rather than doing nothing. That is how a tutorial points
at a key before it lights up.
Actions fan out to one submodule per family beside actions/mod.rs -
audio, cinematic, flow, mission, sequence, ship, spawn, timer,
view - plus registry, which is the macro and the table rather than a family.
Adding one is Extend the scenario engine.
A beat chain keeps its cursor in the engine
A BEAT CHAIN is an authored LITERAL key plus an ordered list of
SequenceStepConfigs, and its state does not live in the action. Running one
files a SequenceRun - key, steps, cursor, the time the step became current -
in the event world beside the keyed timers. The config is immutable and shared
(Arc<Vec<SequenceStepConfig>>), so the same chain can be authored once and
started from several handlers.
Two actions are chains. Sequence calls NovaEventWorld::start_sequence;
Cinematic calls start_cinematic, which files the same run through the same
start_run and adds the ending it owes - whether the player may leave early,
and the events the scene reports when it finishes or is skipped. Name the
mechanism after the CHAIN, not after Sequence: an arm that nests steps is
declared in exactly one place, EventActionConfig::step_chain, and every
walker in the tree reads it. Add a third chain there and nothing else has to
learn about it.
The cursor CANNOT live in the action: handlers are dispatched from an index
snapshot and an action config is read-only during a pass, so a step counter
held there would be per-fire, not per-run. Keying it by an authored literal
also keeps content lint whole-program - no id in authored content is
computed, so the linter still resolves every reference statically.
Three pieces move a chain forward:
advance_scenario_sequencesruns inUpdate, chained aftersample_scenario_queriesand beforetick_scenario_timersandfire_on_update. It drainstake_ready_sequence_stepand runs the actions the step returns. Queries are sampled first so a gate filter reads this frame’s values.- A step’s
untilgate is a real handler.sequence_gate_handlerswalks a handler’s actions and spawns one extraEventHandlerper gated step, carrying a privateSequenceGateAction { key, step }. It is inert unless the cursor stands on exactly that step, so a gate cannot open a chain it does not belong to, and the gate opens the run rather than running the beat. That costs one frame of latency between the gate event and the beat. take_ready_sequence_stepstampssince = nowon the step it hands back, so ONE clock jump delivers at most one step of any one chain. Steps with no delay still collapse into a single pass, because the driver loops.
Both waits on a step apply together, and the semantics are WAIT, never SKIP.
That makes a shut gate a soft-lock, which is why a gated step carries a
deadline: expiry stops the run and logs an error! naming the key, the step
and the event it waited for. start_sequence holds the other loud half - a
restart on a live key is refused and logged, because one key is one cursor.
Because a step’s action list is a FRAME of its own, every walker that reasons
about frames has to recurse into it: inline_queries, object_count, the
lint’s collect_declared / check_action, and the per-event spawn-id pass.
The shared helpers are EventActionConfig::walk (and walk_mut, for a pass
that rewrites) and ScenarioEventConfig::action_groups, which returns a
handler’s own actions plus one group per step of every chain it starts, however
deeply nested. Any new rule that reasons about “one frame” reads
action_groups, not actions.
Variables and the event world (world.rs, variables.rs)
NovaEventWorld holds the scenario state: variables, objectives,
next_scenario, and a queue of deferred command closures. Filters and actions
mutate only this resource, never the Bevy World; world access goes through
world.push_command(|commands| ...). Each frame state_to_world_system syncs
objectives into GameObjectives (write-on-diff), runs a queued non-lingering
NextScenario switch, and drains the command queue.
The drain is CHUNKED, not one flush. A chapter’s OnStart queues a closure per
object, and applying them together cost one ~300 ms frame - a frame nothing can
be drawn on, so the LOADING panel froze on the exact frames it exists to cover.
Commands are applied one at a time until the frame’s budget is spent:
SPAWN_DRAIN_BUDGET (3 ms), or a fifth of the previous frame’s wall time when
that is more (SPAWN_DRAIN_FRAME_SHARE). A big scene arrives over several
frames, a slower machine takes MORE FRAMES, and a frame never gives the drain
more than the fifth of itself a 60 Hz frame does - without the share, a machine
drawing a frame a second landed one object a frame and kept a chapter’s ships a
wall-clock minute behind its scatter. One command per apply is also what
keeps each object atomic: a ship’s sections all land inside one apply, so the
Added<SectionLinkPoints> batch the integrity graph and the derived skin key
off is complete the first time they see it.
While commands remain, the scenario is SETTLING (EventWorld::is_settling).
The dispatcher holds every handler, and a handler that queues world work stops
the current pass, so no handler ever runs against a world known to be
incomplete. The scenario clock stops, keyed timers do not expire, watches are
not sampled, the OnUpdate pulse does not fire, and the LOADING panel stays up.
The world is not yet LIVE, rather than briefly inconsistent. Held events are not
dropped: they dispatch in order on the frame the world goes live.
scenario_has_settled - the run condition the clock and the pulse read - holds
for one more reason: the glTF warm-up above. Dispatch is not, so OnStart
still fires and the scene still builds while the art arrives; what waits is the
scenario CLOCK, so no mission time passes behind a panel the player cannot see
past. It is also what the load gate releases on, so the panel, the clock and
the simulation hold all come down on one frame and one condition.
Variables are typed literals (String, Number, Boolean) with a small
expression tree: VariableExpressionNode (add/subtract), VariableTermNode
(multiply/divide), VariableFactorNode (literal/name/parens);
VariableConditionNode (less/greater/equal) yields booleans for filters.
The tree has a TEXT form (syntax.rs): Display renders it as
scenario.elapsed > 90 and FromStr parses that back. The authored form is
still the RON nest - the text is what one editor row can hold, and what a tree
row reads as when it is shut. Round trip is the contract in both directions,
parse-of-render and render-of-parse, which is what lets a panel own a condition
without a save quietly rewriting it. The syntax spells the grammar and nothing
more: no operator the crate cannot evaluate, and a - b - c parses rightward
because Subtract(Term, Expression) nests that way.
The editor takes a condition APART along the same grammar: each operator is one
document node with its two sides as children, and a leaf holds whatever fits one
row of the text form. Those nodes are NOT part of the tree - the Hierarchy shows
the filter and stops - and the canvas draws them as reporters in the filter’s
sockets, while the Inspector keeps the whole condition as a page, one row per
node, each writing to its own entity. Parens is dropped on the way
in - the nesting says what the brackets said - and put back on the way out
wherever the position needs it, so a sum under a product lowers as (a + b) * 2.
What a switched operator cannot hold it does not keep: an operand a value has no
place for is dropped rather than left hanging where no row would show it.
Two clocks pace a transition
/create/actions/ documents the three gears a scenario switch has - hard cut,
delayed cut and modal hold. The engine fact underneath them is that they do not
all run on the same clock, which is the only part that is not obvious from the
RON:
- A
NextScenariodelay ticks onTime<Virtual>, the pause-frozen scenario clock, so a player who pauses holds the cut. The menu’s one-second cut after a backdrop’sBackdropDone(nova_menu::ambience:: advance_menu_backdrop) ticks on the same clock. The scenario raises the flag in thePostUpdateevent drain, and the menu reads it in the nextUpdate. - An
Outcome’sauto_advance_secscannot, because the overlay it belongs to STOPSTime<Virtual>. It runs on the wall clock instead. A timed banner that used the scenario clock would never fire.
Anything that has to keep counting behind a frozen overlay is in the same position and has the same answer.
Story pacing is a QUEUE, not a slot
NarrativeCue writes into a bottom-left comms stack rather than a
latest-wins line: arrival order, a bounded number of cards visible, and a
bounded pending queue behind them. The whole log stays in the feed either way.
That is why a burst of lines is survivable - but one line per beat is still the
style, and the queue is the safety net. The backlog holds 24 waiting lines,
which is what three cards can reach inside the 30 s a line stays worth reading;
past that the OLDEST waiting line is dropped and the overrun is logged once.
Two consequences for anything that fires story lines:
- The stack is a HUD surface
nova_scenarioreaches up into. The dwell limits and the card budget live with the HUD, not with the action, which is one of the two edges Architecture calls out as running the “wrong” way on purpose. - It is scenario-scoped, so teardown clears the log and nothing bleeds into the next scenario or the menu - the same rule as objectives, HUD readouts and hint emphasis.
Field-level detail (dwell and its clamp, icon, the two lint warnings) is
the authored contract and lives in /create/actions/.
Typed queries and watched variables
The engine exposes read-only world state through typed QUERIES, and a scenario
samples one into a WATCHED variable. /create/expressions/ is the authored
reference for both - the query kinds, their properties, and the beat and wave
shapes built on them. Three mechanism facts sit under it:
- The watch owns the name. A watched variable is written by the sampler
each live, unpaused update, so
VariableSeton that name is REJECTED while ordinary reads -Name("...")expressions,HudReadout- work normally. A variable is therefore either authored or watched, never both. - The clock is not created by exposing it.
nova_scenariokeeps an internal scenario clock for keyed timers whether or not any content asks;Scenario(Elapsed)only publishes it. Both stop together under pause and behind the outcome overlay, and both restart on a retry - which is what makes a run timer show the FINAL time behind a Victory banner instead of counting on under it. Entityis strict-single, and unavailability propagates. Zero matches, several matches, or a match missing the property leaves the query unavailable, and an expression over an unavailable value fails CLOSED. Missing is not zero, which is the difference between a gate that never opens and a gate that opens immediately.- The entity sampler runs only if something reads it. Sampling walks every
EntityIdin the world and allocates per match, so its cost scales with the WORLD - a duel carries about 1,800 ids, most of them ship sections - and not with the scenario.ScenarioConfig::reads_an_entity_querydecides at load, over the watches AND the inline expression factors together, because an inline query has to be answerable the first time its action runs. Anything that adds a new place an expression can be authored has to be added toScenarioConfig::inline_querieswith it.
Watches freeze under pause and clear at teardown, like every other piece of scenario-scoped state.
The OnUpdate pulse SLEEPS (loader/wake.rs)
fire_on_update used to queue an event every frame, and the dispatcher then
walked the whole bucket re-evaluating filters that could not have changed their
answer. It now runs behind a gate, and a scenario with nothing to react to
queues nothing. The rigidbody analogy is exact: the scenario sleeps until
something wakes it.
Two things wake it, both derived at load by wake.rs and held in a
WakeProfile:
- A write.
NovaEventWorld::insert_variableis the single write path, so every write joins a dirty set; the pulse fires when that set meets a variable anOnUpdatefilter reads. What anOnUpdatehandler WRITES joins what it reads, or a counter it advances itself would freeze the moment nothing else in the scenario writes. - A scheduled time.
GreaterThan(scenario_elapsed, 95.0)is known at load, so the crossing is scheduled rather than polled. Only a bare clock read against a literal schedules;scenario_elapsed * 2is arithmetic.
Three properties are worth knowing before reading the code:
- Nothing is authored. The filters already declare all of it. An authored
wake list would be a second source of truth that can disagree with them -
name two variables, read three, and the handler silently never fires on the
third.
/create/does not grow. - The default is
EveryFrame, and it is the fail-safe. A filterlessOnUpdate, anEntityorTimerfilter, an inlineQuery(..), a watch fed by a per-frame sample, or a clock compared against a variable all fall back to the old behaviour. A case the analyser does not understand is SLOW, never wrong. - The decision is per SCENARIO, not per handler. The gate either queues the
event or does not; per-handler gating would mean changing the
nova_eventsdispatcher. One per-frame handler therefore holds the whole scenario awake, and that is sometimes correct - a speed ladder and a HUD countdown are both continuous questions.
A Sequence step’s until gate is a real handler the loader spawns, so it is
scanned with the authored ones. A gate waiting on OnUpdate that was not a
reason to wake would stall its chain forever.
The Ledger’s field trials show both patterns. In
webmods/the-ledger/ledger_05_freight_lane.content.ron, the victory gate reads
arrived and stage. In ledger_03_blue_survey.content.ron, an active scan
writes scan_left for its HUD countdown. That write keeps the pulse awake
until the scan ends or the player leaves its area. Neither pattern needs
an authored wake list.
Scenario patterns
The engine holds three facts for content - once, keyed timers, and a
Sequence cursor - and everything past those is one numeric variable plus
Expression filters. Two variable idioms recur; both are worked end to end in
the Gauntlet worked example below. Excerpts
here are verbatim from webmods/gauntlet/gauntlet.content.ron.
once and Sequence are what a variable is NOT for. A flag whose only reader
is its own filter - seeded in OnStart, read by one gate, written by that
gate’s own action - is the engine’s fact, and once carries it. A step
counter whose only job is to keep paced beats in order is the engine’s fact
too, and a Sequence cursor carries it. Keep a variable where another handler
reads it: an ORDERING counter like gate below, driven by where the PLAYER
is, or a signal like “the convoy lost a ship”.
The gate-counter ordering pattern
A single numeric variable acts as a state machine that enforces ORDERED entry: each stage’s handler is guarded on the counter holding that stage’s value, and the last thing the handler does is bump the counter to arm the NEXT stage only. An event that arrives out of order finds the counter on a different value and does nothing.
Gauntlet’s variable is gate (the index of the gate to thread next, 1..=7).
OnStart seeds it:
VariableSet((
key: "gate",
expression: Term(Factor(Literal(Number(1.0)))),
)),
Each gate’s OnEnter handler carries two filters - an Entity filter that
matches the area/body, and an Expression filter that pins the counter - so
only the in-order entry fires:
(
name: OnEnter,
filters: [
Entity((
id: Some("gauntlet_gate_1"),
other_id: Some("player_spaceship"),
)),
Expression((Equal(
Term(Factor(Name("gate"))),
Term(Factor(Literal(Number(1.0)))),
))),
],
actions: [
ObjectiveComplete((id: "gate_1")),
VariableSet((
key: "gate",
expression: Term(Factor(Literal(Number(2.0)))),
)),
// ... re-point the objective marker at gate 2 ...
],
),
Because gate 2’s handler filters Equal(gate, 2.0), flying through gate 3
early - or back through gate 1 again - matches no live handler and is inert. The
scenario_gate_course rig’s
gates_advance_only_in_order_and_only_for_the_named_ship test pins exactly
this on a synthetic course: an out-of-order entry does not advance gate.
Use it whenever stages must be visited in sequence (a gate run, a guided tour, a
tutorial’s step chain). The base tutorial range uses the same idiom with a
beat counter; see Built-in scenarios.
The act-gating pattern
A post-decision event can otherwise flip an already-decided outcome: in Gauntlet a wreck normally means Defeat, but a wreck that drifts into a rock AFTER the win must not overwrite the earned Victory. The fix is to guard the Defeat handler on the same counter, past a terminal value the winning handler sets.
The FINISH handler bumps gate one past the last real gate (to 8.0, the
terminal done-state) as it declares Victory:
// Terminal: bump past the last gate so no OnEnter re-fires
// AND the player-death Defeat handler (gated gate < 8) can
// never flip an earned Victory to Defeat.
VariableSet((
key: "gate",
expression: Term(Factor(Literal(Number(8.0)))),
)),
Outcome((
outcome: Victory,
message: Some("You ran the gauntlet clean. ..."),
)),
The OnDestroyed Defeat handler is then guarded gate < 8, so a death blast
after the course is finished declares nothing:
(
name: OnDestroyed,
filters: [
Entity((
id: Some("player_spaceship"),
)),
Expression((LessThan(
Term(Factor(Name("gate"))),
Term(Factor(Literal(Number(8.0)))),
))),
],
actions: [
Outcome((
outcome: Defeat,
message: Some("You wore your hull down to nothing ..."),
)),
NextScenario((
scenario_id: "gauntlet_run",
linger: true,
)),
],
),
The rig’s a_wreck_after_the_finish_declares_nothing test seeds gate to
8.0, fires the death, and asserts no outcome and no retry (its sibling
a_wreck_before_the_finish_declares_defeat_with_a_retry pins the other half).
Use this whenever a lethal
event can still fire after the scenario is decided (a boss’s death explosion
catching the player, a wreck sliding into a hazard): pick a terminal counter
value the winning handler sets, and guard every outcome handler against it.
The Gauntlet worked example
webmods/gauntlet is the reference implementation for both patterns. Trace it
end to end:
- The content file
webmods/gauntlet/gauntlet.content.ron- one NEW scenario, no base overrides; the gate-counter and act-gating idioms above live here with header comments explaining the two geometric invariants. - The time-trial wiring:
OnStartfires oneHudReadoutonscenario_elapsed(Timeformat) for a livemm:ss.sclock, and seeds acrashcounter that hazard-zoneOnEnterhandlers bump on each graze. Crossing FINISH bumpsgateto its terminal8.0, then TWOcrash-gatedOutcome(Victory)handlers fire in the same pulse (exactly one matches):crash == 0earns the CLEAN RUN banner,crash > 0the plain finish. The final time is shown by the frozen readout behind the banner (the clock stops on the outcome pause), so the banner text only has to vary the clean-run line - no message interpolation needed. - The test rig
crates/nova_assets/tests/scenario_gate_course.rs- authors a synthetic course as a RON string, drives the real handlers, and pins the ordered-gate sequencing, the repeatable penalty zone, the two counter-keyed win banners, the act-gating and the readout wiring. Run it withcargo test -p nova_assets --test scenario_gate_course.content lintchecks the bundle schema, ids, references and shared balance rules. The Gauntlet geometry invariants - non-overlapping gate areas and racing-line clearance past each rock’s worst-case body - remain manual checks. - The first-scenario guide’s completed flow is the gentler, single-counter cousin of the gate-counter pattern.
Scenario objects (objects/, ScenarioObjectKind)
One module per kind under objects/, one arm in the ScenarioObjectKind match
in actions/spawn.rs. The authored fields of each kind - and every trap in
them - are /create/objects/; what follows is what the modules have in common.
All share BaseScenarioObjectConfig (id, name, position, rotation) and spawn
scoped entities via base_scenario_object, which deliberately carries NO body:
each kind declares its own RigidBody, and only the asteroid and the planet
opt into Dynamic + TransformInterpolation. A carved rock also emits new
dynamic bodies at runtime - every piece a crater severs (CarvedChunkMarker,
integrity/chunk.rs) - so the spawn kinds are not the whole population of a
live scene.
Four engine facts the object configs do not show:
-
BodyRadiusis DERIVED from the mesh, not read from the config. Both ground kinds publishradius * unit_extent, and the two extents are nothing alike: an asteroid’s noise mesh puts it in[3.5, 6.0], a planet’s is1 + relief, about 1.05. Everything measured off a body - the gravity well’s clamp, the sphere of influence, an orbit ring, a GOTO standoff - reads that derived number, so moving content between the two kinds means carrying the DERIVED radius across, not the authored one. -
Nothing supplies a light.
Lightis an ordinary spawned kind (objects/light.rs) and the engine adds none of its own, so a scenario that authors noLightrenders black. This catches every new backdrop. -
What a rock is MADE of is required, and it decides how it looks.
AsteroidConfig::kindnames a KIND -rock,metal,ice,carbon, or theplaincontrol - andobjects/asteroid_kind.rsis the one table that maps a kind to a look. It decides the LOOK and nothing else: a rock’s hit voice isImpactSurface::Rockwhatever it is made of. The look is uniform data for a triplanarExtendedMaterial(objects/asteroid_surface.rs), so a carved and remeshed rock wears the same surface it did before the hit. There is NO default and no fallback: a config without the field fails to deserialize, an id the table does not know is acontent linterror and a refusal to render, andScatterObjectswrites a kind into every copy from an authored weighted mix. -
A rock has no
healthfield, and that is not an omission. What an asteroid is made of IS its durability; the mechanism is below. It has nomassfield either: an asteroid never SOURCES a gravity well - only aPlanetor anAnchordoes - it only ever FEELS one, as a plain dynamic body. What it requires instead is an explicitinitial_velocity(MetersPerSecond3), same as aSpaceship’s: checked finite at lint and again at load, withMetersPerSecond3::ZEROan authored stationary start, not an exemption from gravity. The base-world generator seeds a circular orbit only when its full path stays clear of other wells and within the well’s owning sector. Otherwise it seeds an outward escape inside one well or a zero-velocity coast in free space, by the body’s position (nova_world_base/clusters.rs::generated_initial_velocity); a body reachable by two wells at once fails generation outright rather than blending them.massmoved toPlanet(now REQUIRED) and stayed optional onAnchor(Noneis a zero-strength well, pure geometry). Both still carry the samemu, an L^3/T^2 quantity - an SI one would be a THOUSAND times the world-unit one - so it stays engine-side in u^3/s^2 with theGravitySettingscutoff it is measured against. It sets both the pulla = mu / r^2and the sphere of influence, the distance where that decays toGravitySettings::soi_cutoff_accel(0.25 u/s^2, which is 2.5 m/s^2), so a well is authored by the SOI it should have:mu = soi_cutoff_accel * soi^2, both terms engine-side. The inspection planetoid’s 27 000 buys a 328.6 u sphere of influence, and 328.6 u is the 3.29 km the HUD reads. A negative or non-finite mass is acontent linterror and a spawn refusal. -
Ship section geometry is LINTED, not clamped. Overlapping unit-cube cells and a turret or torpedo mount whose base (local -Y under its rotation) faces an empty neighbour cell are
content lintERRORS, so a bad hull fails authoring rather than spawning wrong. See Ship sections internals.
How an asteroid carves
An asteroid is the one body in the game with nothing to hide behind. A ship carves through its cladding and stops at the structure underneath, because a plate is one cell thick and the hull it is bolted to is a glTF model nothing can cut. A rock is solid all the way down, so a carve here goes as deep as the hit deserves.
The field IS the rock. pristine_field(seed, radius) is the only
description of an asteroid’s shape. pristine_rock_mesh is that field meshed,
and it is what the spawn path draws and collides with; the reseed on the first
hit calls the same function with the same seed and gets the same grid back. It
used to be two shapes - a subdivided octahedron displaced by the noise for the
shipped mesh, and a field for the carved one. They agreed to within a cell, which
is not the same as agreeing: the first hit moved the silhouette and changed the
size of every facet, and that pop was visible on a rock the shot had barely
scratched.
The grid is kept only while it is needed. 140 KB on an arena rock and 275 KB on the biggest the cap allows, and a scenario scatters a hundred rocks most of which are never touched - so the spawn path meshes the field and DROPS it. The first hit pays to build it again (tens to hundreds of thousands of noise samples); from then on nothing resamples.
The cost model. The field is what the rock is meshed and collided from, so
it is engine geometry: every figure in this paragraph is in world units, and the
authored Meters radius crosses once on the way in (pristine_rock_mesh).
FIELD_CELL_WORLD is 0.5 WORLD units - a 5 m cell - and the cell COUNT is
derived from it, the opposite way round from how this started. A crater is a
world-sized thing (a 4-damage PDC round carves 0.62 u, a 6.2 m sphere, whatever
it lands on), so a grid whose cells grew with the rock could not draw that
round’s hole on anything big: 32 cells across a 30 m rock is a 1.02 u cell, four
times the round being fired at it. Coarseness is the ART, not a resolution knob
- a finer grid only makes a smoother rock.
FIELD_RESOLUTION_MINis 16 andFIELD_RESOLUTION_MAXis 40; the cap BINDS above an authored radius of about 18 m, and what it costs there is the cell (a 30 m rock is gridded at 0.82 u, so a PDC round on one is under a cell and only sustained fire - whose mark GROWS where it is held - opens a hole).41^3corners is 275 KB per carved rock, paid only by rocks that are hit.FIELD_MARGINis 1.08, only just over 1 because carving never ADDS material.
Nothing happens in the frame that asked for it. The seed and the whole carve
- split, surface nets, collider, and the geometry of every piece the cut freed -
run on the async compute pool, at most one job in flight per rock, and the rock
keeps drawing the surface it already had until one lands. What the main thread
pays is the sphere subtraction the mark itself reaches, and then PLACEMENT: one
transform and one spawn per piece. A remesh also waits until the grid actually
loses a cell (a quantized
meshed_volumecompare), so sub-cell hits accumulate in the field without paying for connectivity, surface generation or a collider rebuild.
CarveApplyReport is what holds that line, and it counts GRIDS rather than
milliseconds because a count reads the same on every box. One grid per rock is
its own new solid; one per PIECE means the main thread is holding a
quarter-megabyte field to ask it questions each of which is a scan of all of it.
It used to. Three rocks landing in one frame with five pieces between them
measured 17.5 ms in that frame against a 0.02 ms median; the same frame with the
pieces built on the worker measures 0.03 ms. bug_carve_apply is the range that
holds it there.
Severing and death. SignedField::split_off_islands
(crates/nova_gameplay/src/mesh/field.rs) hands back whatever the cut freed. A piece past CHUNK_MIN_VOLUME
becomes a rigid body of its own, meshed by the SAME surface nets the rock is,
carrying v + omega x r; anything smaller is announced as a carve and goes out
as dust. Both decisions are the worker’s: the threshold is a WORLD volume and the
grid is in the rock’s own unit space, so the job is told the scale it cannot see.
When the remaining solid falls under CHUNK_MIN_VOLUME, or the surface
comes back empty, the rock inserts IntegrityDestroyMarker, fires
OnDestroyedEvent itself and despawns its root - so a rock’s OnDestroyed comes
from nova_scenario, not from nova_gameplay’s integrity stack.
BodyRadius only ever SHRINKS. Everything sized off a rock’s surface -
standoff distances, orbit clearances, the sphere of influence - was authored
against the pristine radius, so shrinking keeps every one of those valid and
growing would silently invalidate them. The collider density rides along
unchanged, so avian re-derives mass from the volume that is left: a carved rock
is a lighter rock.
The surface is sampled by POSITION. AsteroidSurfaceMaterial / RockHeight
are triplanar in the body’s own local space, so a carved rock wears exactly the
surface an uncarved one does and there is no per-triangle quilting. It is also
why a severed piece must not inherit that material blindly - a piece is a new
body with a new origin, and it reads the grain from a different place.
Built-in scenarios
The builders live under crates/nova_authoring/src/base_content/scenarios/.
main_menu/ gives each menu backdrop its own file, open_world.rs builds the
New Game start (the player’s warship and lights on a
world: Generated((seed: None))), tutorial/ builds Basic Training across
mod.rs (the chain), range.rs (the props) and script.rs (every line), and
pacing.rs holds the cue and outro spacing. Basic Training is the beat-chain reference: one beat counter gates
every handler, and count milestones run on OnUpdate handlers keyed on the
counter (handler order within one event is not load-bearing). The builders are
an OFFLINE inventory, not the runtime path: content -- gen serializes them to
the committed assets/base/scenarios/*.content.ron, the bundle manifest lists
them, and crates/nova_assets/src/merge.rs merges the parsed RON into
GameScenarios like any mod’s. content_ron_parity pins builders == RON.
The orbit tracker derives lifecycle edges from live autopilot state. Once the
ship first reaches Hold it also sums signed radial-angle changes around the
sticky orbit plane, and each net revolution fires OnOrbitLap. Counting starts
at the RING, not at the verb, because the insertion approach curves around the
well and would otherwise bank most of a lap for free. It then survives every
phase the autopilot flies there - Align and Burn during ORBIT are the
correction that puts the ship back on the ring, not a departure from it - and
is written off only by an absence from Hold longer than
ORBIT_LAP_GRACE_SECS. So authored lap objectives need no guessed timer, and
one nudge does not silently cost the player three quarters of a revolution.
The docking tracker reads its two edges off the CONNECTION entity rather than
off either hull. DOCK builds exactly one connection per pair and every way
out of a dock - either pilot’s verb, a shot-off port, a dead ship - despawns that one entity, so comparing a live
connection against the DockEcho BOTH hulls carry reports one OnDocked and
one OnUndocked on every path, with no branch per ending and nothing in
nova_ship knowing what a scenario event is. The echo retains the payload, not
the two entities, because the release edge has to name a partner that may
already be gone - which is also why both hulls carry it: a pair whose asking
hull is destroyed still reports through the survivor, and the two echoes are
deduplicated into one release. Only a pair that dies whole falls silent, the
way orbit end and area exit do for a despawned body.
The hold events cross the crate line the same way ship orders do: a queue
component. ShipInventoryReports (nova_gameplay) is required by every
spaceship root, and the owning system pushes one report per occurrence - the
cargo intake for a taken or dropped stack, the inventory pane for a confirmed
trade and each purse it moved or a station store or withdraw, the ship pane for a repair, a rebuild, a swap, an add, a hull buy, an escort hire, a station buy or a finished strip. Credits have one
choke point, ShipInventoryReports::change_credits, which is the only
post-spawn writer and reports nothing for a no-op. track_ship_inventory_reports
drains the queue in FixedUpdate after SpaceshipSectionSystems and fires
OnPickedUp, OnJettisoned, OnTraded, OnCreditsChanged, OnRepaired,
OnRebuilt, OnSwapped, OnAdded, OnShipBought, OnEscortHired, OnStationBought, OnStripped, OnStored and OnWithdrawn;
pane reports pushed under the interface pause arrive on the first fixed tick
after it closes. OnMined has no queue: pulse_mining_beams (mining.rs)
fires it per pulse that flips at least one corner, with the rock as id and
the miner as other_id, because the pulse is already a scenario-crate system.
Adding new pieces
- Event: event + info structs in
nova_events/src/lib.rs, anEventConfigvariant inevents.rs, and something that fires it (engine-driven events live inloader/-OnStartinlifecycle.rs,OnUpdateinclock.rs, the orbit/lock/docking trackers intrackers.rs; area events inobjects/area.rs;OnNeutralizedfires fromnova_gameplay’s integrity stack, a rock’sOnDestroyedfromobjects/asteroid_carve.rswhen its field is exhausted, the hold events fromtrack_ship_inventory_reportsintrackers.rs, andOnMinedfrommining.rs). A new occurrence belownova_scenariopushes aShipInventoryReportvariant; it does not get a second queue. - Action: config struct +
EventAction<NovaEventWorld>impl in the rightactions/submodule (audio/cinematic/flow/mission/sequence/ship/spawn/timer/view), plus a row in theregistry::scenario_actions!table inactions/mod.rs. TheEventActionConfigvariant is GENERATED from that row - writing one by hand collides with the macro. - Filter: same pattern in
filters.rs(EventFilterConfig). - Object: a module under
objects/(config + bundle function, plugin inobjects/mod.rs) plus aScenarioObjectKindvariant/match inactions/spawn.rs. Its*_TYPE_NAMEconst goes innova_events, besideEntityTypeName, so readers belownova_scenariocan match on it.
Find it in the code
- Engine plugin:
NovaScenarioPlugin-crates/nova_scenario/src/lib.rs; generic dispatch:EventHandler-crates/nova_events/src/engine.rs. - Vocabulary enums:
EventConfig-crates/nova_scenario/src/events.rs;EventFilterConfig-crates/nova_scenario/src/filters.rs;EventActionConfig-crates/nova_scenario/src/actions/mod.rs. - The state seam:
NovaEventWorld-crates/nova_scenario/src/world.rs; variables and expressions -crates/nova_scenario/src/variables.rs; their text form:crates/nova_scenario/src/syntax.rs. - What an authored string names:
Names-crates/nova_scenario/src/names.rs; the editor that reads it -crates/nova_editor/src/event.rs(the script as nodes). - Loading and scoping:
ScenarioLoaderPlugin,ScenarioScopedMarker,scenario_is_live-crates/nova_scenario/src/loader/mod.rs; the glTF warm-up:ScenarioPreload,scenario_render_meshes-crates/nova_scenario/src/loader/preload.rs; the load hold and its failure:ScenarioLoadGate,scenario_play_is_free-crates/nova_scenario/src/loader/gate.rs; what the pulse wakes for:WakeProfile,configure_scenario_shape-crates/nova_scenario/src/loader/wake.rs. - Objects:
ScenarioObjectsPlugin-crates/nova_scenario/src/objects/mod.rs; kind dispatch:ScenarioObjectKind-crates/nova_scenario/src/actions/spawn.rs. - Asteroid carving:
AsteroidField,carve_asteroid_fields,pristine_field-crates/nova_scenario/src/objects/asteroid_carve.rs; the mesher underneath:SignedField-crates/nova_gameplay/src/mesh/field.rs; the cost of the material itself:DAMAGE_PER_UNIT_VOLUME-crates/nova_gameplay/src/integrity/carve.rs. - Asteroid kinds:
AsteroidKindId,AsteroidKind,asteroid_kind_look,ASTEROID_KINDS-crates/nova_scenario/src/objects/asteroid_kind.rs; the surface they drive:AsteroidSurfaceMaterial-crates/nova_scenario/src/objects/asteroid_surface.rsandassets/shaders/asteroid_surface.wesl. - API detail:
cargo doc --open -p nova_scenario(event engine:-p nova_events).
The automation harness
How Nova Protocol drives itself without a human at the keyboard: a scripted autopilot walks the game through its states, screenshot drivers capture the result, and a completion protocol decides when the run is over. It is what makes headless verification, the web figures and the performance reports repeatable rather than a manual pass.
All of it lives in the nova_autopilot crate, which depends on bevy alone -
no nova_* crate, no avian3d. It ships the drivers and the protocol; it does
not ship anything Nova-specific. The adapters that know about Nova (scenario
presets, camera posing, freezing rigid bodies, hiding the dev overlay) stay in
nova_debug and reach in through caller-supplied closures. The drivers are
generic over the app’s state type, and that generic is what keeps
GameStates - and with it the whole game dependency tree - out of the crate.
Read this page as the crate’s contract. Nova’s own examples run these
drivers, reaching them through the nova_debug prelude and the
nova_debug::harness presets - the Nova-flavored adapter, not a second
implementation - while nova_probe names nova_autopilot::completion
directly.
What it drives
| Driver | Does | What Nova uses it for |
|---|---|---|
AutopilotPlugin | Walks a list of named steps, each advancing when its predicate over the world holds | Headless smoke runs, driving a scenario while something else measures |
capture_window | Writes the primary window to a PNG and acks it into CaptureLog. Not a driver - the primitive a script’s shot step calls | The web figures and thumbnails, captured at 1920x1080 |
completion | The registration and exit protocol every driver reports to | Ending a run once, when everyone is finished |
nova_probe_cli (the game binary’s probe subcommand, debug feature only) is the host layer above: it arms the
harness variables, spawns a subject as a child process and turns the output
into a correctness and performance report. It arms the variables below -
including a NOVA_AUTOPILOT_DEADLINE sized to the capture window for its fps
pass and to --timeout for its behavioral pass, which your own value
overrides. Its in-game half, nova_probe, is what the subject
wires to collect the evidence.
A harness run is headless, which for these drivers means a SOFTWARE X server, and that is not free: presenting a window under Xvfb costs a CPU copy of every pixel, charged to the frame. Correctness is unaffected and so is any ratio, but an absolute millisecond off a headless run is the game plus the display server. Measuring performance has the size of it and what survives.
The subject is usually an example, which wires the collectors itself. For
probe scenario it is the GAME BINARY: src/main.rs adds
nova_probe::NovaProbePlugin and the nova_autopilot() preset under the same
debug feature that carries the probe subcommand. Both are inert without
their variables, so a plain cargo run --features debug behaves exactly as
before - and a scenario becomes measurable without an example file existing for
it.
The environment contract
Every driver is inert unless its own variable is set, so an app adds the plugins unconditionally and a normal run pays nothing for them. Setting the variable is what arms the driver; the value only matters where the table says so.
| Variable | Arms | Read by | Value |
|---|---|---|---|
NOVA_AUTOPILOT | the scripted state driver | AutopilotPlugin | any (presence only) |
NOVA_CAPTURE | the CAPTURE path of a script that has one: its shot steps write PNGs instead of driving straight through | capturing(), which a script reads while building its steps | any (presence only) |
NOVA_CAPTURE_DIR | nothing on its own | capture_window, and the scenario Screenshot action (nova_scenario/src/actions/view.rs) reads it independently | directory that relative capture paths resolve under; absolute paths ignore it |
NOVA_CAPTURE_SIZE | nothing on its own | capture_size_override, applied by force_capture_resolution | <width>x<height> in pixels, in place of 1920x1080; a malformed value panics |
NOVA_CAPTURE_UI_SCALE | nothing on its own | force_capture_resolution, which sets the UiScale factor | 75, 100, 125 or 150; any other value panics |
NOVA_AUTOPILOT_DEADLINE | nothing on its own | the completion watcher | seconds before the run gives up and error-exits naming the laggards (default 120); the RUN-level backstop under a script’s own per-step deadlines |
NOVA_CAPTURE arms the SHOTS, never a driver. A capturing run therefore sets
NOVA_AUTOPILOT too, and one script owns the window: there is no second driver
to fight it over NextState.
That is the DRIVER contract in full - four variables. It is not every NOVA_*
variable the workspace reads: Environment variables
indexes the whole set and says which crate owns each, and the measurement
knobs’ own values and defaults are tabulated once in nova_probe’s crate
rustdoc - cargo doc --open -p nova_probe - because the same table serves the
wasm build as URL query parameters. Measuring performance
covers what they are FOR. A variable that arms nothing is silent, so a run
pinned to a name that is on none of those pages does a plain play-through and
reports nothing wrong.
Reading the world instead of looking at it
The timeline says what HAPPENED. The world-state snapshot
(nova_probe::capabilities::snapshot) says what the world LOOKS like: one JSON
object holding every ship - identity, pose, velocity, aggregate health, mass,
the collapse/defeat flags, its weapon locks and its capabilities - each ship’s
sections with their class, pose, health and magazine state, each section’s
fixtures
(the skin plates and decor bolted to it), and every torpedo and round in flight
with its owner, damage, remaining lifetime and - for a torpedo - which ordnance
TYPE it is, since two bays on one hull can load different torpedoes that are
identical in every other field.
Use it when a defect would otherwise be judged from a render. A skin bug, a
section that took damage it should not have, a turret that never reloaded: all
of them are one jq query away instead of a picture to squint at.
Xvfb :95 -screen 0 1280x720x24 &
NOVA_AUTOPILOT=1 NOVA_PROBE_SNAPSHOT=/tmp/snap.jsonl \
NOVA_PROBE_SNAPSHOT_FRAMES=600,600 BEVY_ASSET_ROOT="$PWD" DISPLAY=:95 \
cargo run --example system_turret_gunnery --features debug
jq -S '.ships[0].sections[] | {id, class, health}' /tmp/snap.jsonl
Two rules make it a DIFFABLE artifact rather than a dump. Every list is sorted
by a value-derived key, never by entity id or query order, so a respawn that
renumbers entities does not churn the diff. Every float is rounded to four
decimals with -0.0 normalized, so the last bit of an f32 does not either.
Two snapshots of one frozen frame are byte-identical, which is what the repeated
frame number above checks.
It is read-only, deliberately. There is no restore: a scenario is already replayable and is its own checkpoint.
The completion protocol
One run can carry several collectors - an autopilot timeline, a frame capture -
each finishing on its own clock. A collector that writes AppExit on its own
clock discards every other collector’s data, and does it silently: a capture
cut short by another collector’s exit still writes a plausible file, just a
shorter one. So the exit is NEGOTIATED. Two rules:
- Register before the run starts. A collector calls
completion::registerfrom itsPlugin::build, behind its own armed check. Nothing joins later, and an unarmed collector must not join at all - it would hold the exit open until the deadline. - The app exits only when every registrant reports done. A collector calls
HarnessCompletion::doneand never writesAppExit::Successitself; the watcher writes it once the pending set empties.
An error exit is the exception: a collector that genuinely fails (a screenshot
that cannot save, a stalled script) writes AppExit::error directly, because
an abort is not a completion and must not wait for anyone. The deadline backstop
does the same, naming the collectors still pending, so a supervisor reads
“capture never completed” in the log instead of watching a silent hang.
Writing a script
A script is a list of STEPS. A step is a name plus four optional parts and one required one:
| Part | Meaning |
|---|---|
name | what a log line and a stall message call this beat |
enter | the state to set on entry |
on_enter | a world action run once, on entry (a synthesized gesture, a scenario poke) |
each | a world action run every frame, with the IN-STEP elapsed seconds and the IN-STEP frame index |
until | the predicate that advances the step |
deadline | in-step seconds after which an unsatisfied until ABORTS the run, naming the step |
diagnose | a reader of the world appended to that abort message, so the stall says WHY |
The step advances the first frame until holds. Name a step after what it is
waiting FOR, not after what it pokes - the name is what a stall message
carries, and “stalled on lock the prey” is a diagnosis where “stalled after 30
seconds” is a shrug.
Elapsed time is one predicate among many, and there is no second mechanism
beside the step list: “wait N seconds in this state” is
step("...").enter(state).until(elapsed(secs)), written out where anyone
reading the script can see what it waits for. The vocabulary is in
nova_autopilot::predicate (elapsed,
frames, state_is, resource_where, any_entity, ui_node_present,
pointer_pressed, pointer_released, pointer_at, pointer_at_node,
window_size_is, window_scale_factor_is, shot_written,
loop_written, and, or, not), the
gestures in nova_autopilot::input (press_key, release_key, type_text,
press_edit_key, press_mouse, release_mouse, move_cursor, click_at,
click_named, scroll_lines, scroll_pixels), and the Nova-typed
predicates in nova_debug::harness (scenario_variable_is, section_gone,
player_ship_present, and the editor_* set below). Anything the vocabulary
cannot express is a plain closure: Arc::new(|world: &World| ...).
A key press is two channels, and a beat has to pick
Bevy delivers a key twice. press_key writes ButtonInput<KeyCode>, which is
what a VERB reads. press_edit_key writes a KeyboardInput message, which is
what a TEXT FIELD reads. Nothing bridges them, so a beat that drives one channel
proves nothing about the other: pressing only KeyCode::Delete at a focused
field asserts that the verb stayed quiet while the field was never asked to
delete anything, and pressing only Key::Delete asserts the opposite half.
A beat about who owns the keyboard therefore drives BOTH and reads both
outcomes - see examples/systems/system_input_modes.rs, which presses each key
down both channels and then asserts what the field holds as well as what the
document holds.
A UI gesture is four waits, not four sleeps
A click on a widget owes the app four acks, and each one is a predicate:
| Beat | Waits on | Why a frame count is not that |
|---|---|---|
| the widget is up | ui_node_present(name) | click_named WARNS and continues on an unresolved name, so a press at a panel that has not laid out is a beat silently lost |
| aim | pointer_over_node(name) | a resolvable rect is not a clickable widget: an overlay on its way out takes every pick, and a reflow moves the widget out from under an aim already sent |
| press | pointer_pressed() | the picking backend turns the button event into pointer state a PreUpdate later, and a release before that is a click the widget never saw |
| release | pointer_released() | Activate fires on the release edge, so this is the beat the button’s effect belongs after |
Two things those predicates are careful about, because both were wrong once.
ui_node_present waits for a BOX, not for an entity: a node that has not been
through ui_layout_system carries ComputedNode::default() - zero size at the
origin, the same value a Display::None node keeps - and advancing on that
hands click_named a degenerate rect at the window corner, which is the race
the settles existed for. And the pointer acks answer for PointerId::Mouse
alone, because that is the pointer the gestures drive; an app may carry others
(Nova’s terminal parks a forwarded one), and a second pointer sitting in the
opposite state would otherwise ack for the one the beat actually moved.
A headless range that clicks UI must SPAWN A WINDOW, as
system_headless_drag does. nova_core holds UiSystems::Layout while any UI
root is aimed at a target with no area - a zero-size target makes taffy hand
bevy a negative node size, which panics - so an app with no window at all never
lays its UI out and no ui_node_present beat can ever advance.
The aim beat re-hovers EVERY frame rather than holding the coordinate it first
sent, so a reflow moves it along instead of stranding it; it carries
pointer_hover_diagnosis(name), which names the occluder that took the pick.
Those four beats are one builder, not a shape each script re-types:
AutopilotPlugin::click_named(label, name, landed, deadline), with
double_click_named beside it. landed is the caller’s own condition - what
the click was FOR - so a click whose effect never arrived stalls on the beat
that made it. pointer_released() is the weakest thing to pass and the right
one where the effect is waited on by the beat after.
The layout beat carries ui_node_diagnosis(name), so a stall there says which
of the four ways ui_node_present can be false actually happened: no such name,
a name hidden by its ancestry, a visible node with no laid-out box, or no UI
node type registered at all. Those are three different bugs with three different
fixes, and the deadline message used to name none of them.
What the button DID is the caller’s next beat, and it is a condition too. The
editor publishes its own decisions as data for exactly this: nova_editor’s
public, read-only EditorProbe carries the armed tool, what a click would build
right now (solved, refused with the reason, or nothing), whether the parts
gallery is up, whether its filter holds the caret, and which prototype the
selection resolves to. nova_debug::harness wraps those as editor_tool_is,
editor_part_armed, editor_placement_solved, editor_placement_refused,
editor_placement_clear, editor_gallery_open, editor_gallery_closed,
editor_filter_focused and editor_gallery_selected.
The probe exists only in a debug build, which is the only build that has
those predicates: nova_debug and the snapshot are linked by the same feature.
A shipped editor does not carry it, and does not pay the document sweep that
fills it.
So a placing gesture reads: aim, hold until the editor SOLVED a placement there;
press, hold until the pointer registered it; release; hold until the section
landed. system_ship_editor and the screenshot_editor walk are written that
way end to end, and neither counts a frame outside a pre-shot settle.
Where the only observable IS the outcome, the beat may share the assert’s
quantity - the exception to the strictly-weaker rule below. “The socket
filled” has no stimulus-side twin: the count is both what the beat waits for and
what the verdict states. The trade is deliberate, and it is the better half: an
unmet until aborts naming the beat that missed, where a snapshot assertion a
fixed number of frames later reported a wrong number and left which gesture
produced it to be guessed. Keep the assert anyway - it is where the claim is
written down.
Typing is its own gesture because a key has two halves. press_key writes the
HELD state (ButtonInput<KeyCode>), which is what flight code polls;
type_text writes the keyboard MESSAGE carrying the text a keypress produced,
which is what a text field reads. A run that drives a filter or a name field
wants the second - pressing KeyR fills nothing in.
A driven run owns the pointer. The examples run on a real display, so a
real cursor event - the window manager’s enter/motion pair, a developer nudging
the mouse, the echo of the OS-level warp the driver itself performs - lands in
the same stream the synthesized one does. One of those between a press beat and
its release beat CANCELS the click silently: bevy_picking dispatches
Pointer<Click> from the PREVIOUS frame’s hover map, so a pointer that moved
off the widget in between produces a release, no click, and no Activate - a
90-second stall on the beat AFTER the one that actually went wrong. So the
autopilot pins its pointer: whenever the window disagrees with the last
position a gesture set, nova_autopilot::input puts the pinned position back
in First, before the picking backend reads the frame’s events. Nothing at a
call site changes, and a script needs no defensive re-hover. The pin holds the
REAL cursor too, since that is what Window::cursor_position moves - so a
driven run on your own desktop pulls the mouse back whenever you move it off,
until the run ends.
A settle predicate over a physics quantity belongs on the physics schedule.
“The value held still for N frames” is a common way to write “the solve is
done”, and on Update it does not mean that: avian runs in FixedPostUpdate,
so above the fixed rate most Update frames carry no solve pass at all and N
unchanged frames can all precede the recompute the beat is waiting for. Sample
in FixedPostUpdate after PhysicsSystems::Prepare, so one sample is one
pass, and carry any second fact the beat needs (an entity count, say) in the
same sample rather than reading it live off the world. system_hull_damage’s
ComSettle is the worked example.
The rule is “sample where the quantity is WRITTEN”, not “sample on the fixed
schedule” - system_turret_gunnery’s AimSettle stays on Update precisely
because its aim error is produced by SmoothLookRotationPlugin in
PostUpdate, so consecutive fixed ticks inside one frame would read the same
value and saturate
the streak mid-slew. What that costs is framerate independence, which you buy
back separately: a per-frame delta threshold means a different physical
threshold at every framerate, so compare a RATE against Time::delta_secs().
AimSettle is the worked example for that half.
Related, and the other half of the same mistake: a beat must be strictly
weaker than the assert that follows it. If the beat’s predicate and the next
step’s assert share a constant, the assert cannot fail and a real regression
surfaces as a deadline stall on the beat’s name instead of the message that
explains it. Gate on the stimulus and the world having stopped changing; assert
on where it ended up. When the two must read the same quantity, give the beat a
margin - the driver enters the next step on the FOLLOWING frame, so a beat that
opened at exactly the assert’s threshold on a falling value hands the assert a
failing one (system_attitude_hold’s OFFSET_BEAT_MARGIN_SECS, and
system_turret_gunnery’s GATE_TRAVEL_BEAT_MARGIN over a sweep that is not
even monotonic).
Where the invariant has no separate stimulus-side observable - “the torpedo
detonated”, “the burn accelerated” - the beat is the stimulus plus a bounded
settle sized off the mechanism (LAUNCH_SETTLE_SECS, BURN_WINDOW_SECS,
HIT_SETTLE_SECS), never the outcome itself. A settle is a runway, so it owes
a derivation on its constant; what it must not do is read the quantity the
assert decides.
What a frame count is for
frames(n) stays in the vocabulary, with a narrow job. A frame is not a unit of
work - the same count is milliseconds on a workstation and most of a second
under lavapipe - so a count is only honest where the thing being waited for is
counted in frames. Four cases qualify:
| Case | Example |
|---|---|
| render, raster or shader work coming to rest before a shot | frames(SETTLE_FRAMES) ahead of shoot |
| deterministic media duration | the loop_* ranges, where LoopProfile pins the frame clock so a count IS a webm length |
| a verdict that NOTHING happened | “the pointer stayed put while a stray moved” has no positive event to wait on |
| deliberate separation between an action and the assert that tests it | keeping a beat strictly weaker than the claim it precedes |
Everything else is a guess at how long hidden state takes to change, and the
state is nearly always readable: a window resize lands in
Camera::logical_viewport_size, a DPI change lands in
ComputedNode::inverse_scale_factor, a shell command lands in the terminal’s
own scrollback revision. Wait on the landing. frames(1) in particular is
nothing at all - a step with no until already advances on its first driven
frame - so it says only that its author wanted a step.
Deadlines come from two shared constants rather than a copy per file:
STEP_DEADLINE_SECS (30s, a world condition) and BEAT_DEADLINE_SECS (20s, one
gesture), both in nova_debug::harness.
The runway anti-pattern
The shape to avoid is a wall-clock RUNWAY plus one closure that re-derives a step machine from booleans:
#![allow(unused)]
fn main() {
app.add_plugins(
AutopilotPlugin::<GameStates>::new()
.self_completing()
.step("hold") // a runway, unrelated to Loading
.enter(GameStates::Loading)
.until(elapsed(30.0))
.add()
.input(script), // every frame, in every state
);
app.add_systems(Last, guard_script_completion);
fn script(world: &mut World, elapsed: f32, _frame: u32) {
if *world.resource::<State<GameStates>>().get() != GameStates::Playing {
return;
}
// Re-derive a step-relative clock by hand, because `elapsed` is the run's.
let playing_since = { /* get_or_insert into a script resource */ };
let t = elapsed - playing_since;
if t > 1.0 && !script.spun { script.spun = true; apply_spin(world); }
if t > 2.0 && !script.killed_controller { /* ... */ }
// ... and a hand-rolled panic if the runway expires with beats unplayed.
}
}
Everything in it is a symptom: the playing_since offset exists because the
closure’s clock is the RUN’s and not the beat’s, the booleans exist because a
closure has no notion of having advanced, and the hand-rolled guard exists
because nothing else knows the runway expired with beats unplayed. Each is
carried by the step list for free.
Write the beats themselves, each waiting on the world
(examples/systems/system_hull_damage.rs):
#![allow(unused)]
fn main() {
app.add_plugins(
AutopilotPlugin::<GameStates>::new()
.step("load the rig")
.enter(GameStates::Loading)
.until(player_ship_present()) // not "30 seconds should do it"
.deadline(15.0)
.add()
.step("spin the ship")
.on_enter(apply_spin)
.add()
.step("kill the controller section")
.on_enter(kill_frontmost_section)
.until(section_gone("controller")) // the real despawn, not a guess
.deadline(6.0)
.add()
// Last beat: the driver reports done after it, so the run ends on the
// assertion instead of idling out the rest of a runway.
.step("assert the com follows the surviving sections")
.on_enter(assert_com_follows_sections)
.add(),
);
}
There is no runway, and a stalled step is the driver’s job to report, by name.
Capturing: one idiom
There is one way to take a screenshot from a script, and it is a step. A shot
step calls shoot(world, "name.png") from on_enter, so the act, the framing
and the shot it produces read top-to-bottom in the step list:
#![allow(unused)]
fn main() {
.step("frame the planetoid")
.on_enter(|world| pose_camera(world, EYE, LOOK))
.until(frames(SETTLE_FRAMES))
.add()
.step("shoot wiki-gravity.png")
.on_enter(|world| shoot(world, "wiki-gravity.png"))
.until(shot_written("wiki-gravity.png"))
.deadline(SHOT_DEADLINE_SECS)
.add()
}
shoot is its own gate: it captures only when NOVA_CAPTURE is set, so the
SAME script is both the capture run and the smoke run.
shoot is asynchronous (the PNG lands at the end of a later frame), which is
why the shot step holds instead of ending immediately: move the camera in the
same frame and the pending capture renders the NEXT framing. It holds on the
ACK, not on a guessed number of frames - capture_window records the path in
CaptureLog once the write completes, and shot_written reads that. On the
smoke path, which shoots nothing, the same predicate holds immediately, so a
script never branches its step timing on capturing().
That leaves ONE settle in a capture script, the same on both paths:
SETTLE_FRAMES, the frames a beat needs to come to rest before its shot. The
per-example splits that preceded it were each carrying the write latency on top
of the stillness, which is why they disagreed.
The scene dressing is separate from the steps and lives in nova_debug::harness
too: force_capture_resolution (a known framing for every shot in the fleet),
hide_dev_overlays / hide_hud, and freeze_bodies for a posed set that must
not drift between framings.
A moving figure is the same idiom on a longer beat: loop_start / loop_end
around the action, loop_written to hold the closing step, and
LoopCapturePlugin next to the script. Every size and rate that recorder uses
is ONE value, LoopProfile - the window it renders into, the resolution it
encodes to, the frame clock, the CRF and the frame cap:
#![allow(unused)]
fn main() {
app.add_plugins(LoopCapturePlugin::default()); // 1920x1080 -> 720p30
app.add_plugins(LoopCapturePlugin::new(LoopProfile { // a portrait master
window_resolution: (1080, 1920),
output_resolution: (1080, 1920),
fps: 60,
crf: 18,
frame_cap: 1200,
}));
}
LoopCapturePlugin::default() is what the docs fleet captures with; a
production caller passes its own profile and changes nothing else.
force_capture_resolution reads the profile the plugin publishes (armed or
not), so the window, the frame clock and the encode cannot disagree about the
framing - and a profile whose output equals its window encodes with no scale
filter at all, rather than resampling a master through itself.
A loop also records what it HEARD, with no extra step calls. DebugPlugin
observes the recorder’s LoopCaptureStarted / LoopCaptureFrame /
LoopCaptureEnded events and samples each voice’s engine-resolved VoiceMix
immediately before each frame request. At loop_end it stages the audio and
writes the sidecar, and the drain that follows encodes the WebM:
<name>.webmwith VP9 video and an Opus track, rendered offline from the loaded samples at each frame’s pre-masterVoiceMix::channel_gainsand speed. A positional voice plays the sum of its sample’s channels at its per-ear gains, as the spatial sink does; a flat voice keeps its channels. A voice that the voice cap or a frozen sim holds is silent and paused. A one-shot plays its whole sample; a loop ends with its last frame. If the summed PCM peak is over -1 dBFS, the whole loop is attenuated once to put it there before the Opus encode, which can overshoot it on decode; a quieter loop is never boosted. The log names the original peak and the attenuation.<name>.jsonl, the version 1 SFX sidecar for the editor: a header{version, loop, fps, frames}, then{f, v, gain}rows withclipandloopingon a voice’s first row.gainis the pre-master sink gain, the editorial contract, so it carries neither the pan nor the WebM’s peak attenuation. A held voice’s row has gain 0. Speed is not in version 1.- each named sample at its asset-relative path beside the sidecar. A sample
that cannot be copied warns. Any other sidecar or audio failure, such as a
voice with no asset path, a path that is not plain relative names, a path
under the
.loop-framesstaging directory, or an existing symlink, non-directory parent or uninspectable entry on a sample’s path under the capture directory, aborts the run before the encode, soloop_writtennever acks a silent loop. The destination check runs before any write but does not stop a link created during the copy, and it does not detect a hardlinked sample file. The sidecar and samples are on disk before the encode, so an ffmpeg mux failure after them fails the run but leaves the complete sidecar.
A Bevy-only app with LoopCapturePlugin and no DebugPlugin still writes a
silent WebM. Sheets record no audio.
A SPRITE SHEET is the same recorder with a grid for a length. The training
handbook draws a lesson loop from one image of cells - the game has no video
decoder, so an atlas is how a panel animates - and a producer opens the
recording with sheet_start(world, name, grid) and holds the closing step on
sheet_written(name). There is no sheet_end: the grid IS the length, so the
recorder stops at SheetGrid::frames() frames, tiles them left to right and
top to bottom, and acks <name>.png itself. Calling loop_end on a sheet is
an error that names the predicate to hold on instead.
#![allow(unused)]
fn main() {
sheet_start(world, "flight_aim", SheetGrid {
columns: 4, rows: 5, cell: (960, 540), // the grid the LESSON authors
});
}
The cells are recorded frames, so what happens under a sheet has to survive
the WRAP: a sheet plays on a cycle, and twenty frames at ten a second is two
seconds of it. examples/screenshots/shared/lesson.rs carries both answers. A
POSE loop freezes the scene and puts the camera on one period of a sine arc, so
the last cell hands back to the first. An ACTION loop holds the camera still
and lets the act be the motion - a few cells before, the press, and the rest of
the sheet holding the result - which wraps the way a tutorial clip wraps. When
the act is the SHIP MOVING, a camera fixed in the world loses it inside two
seconds, so LessonChase holds a fixed offset IN WORLD AXES from the moving
hull: the ship keeps its place in the cell and its attitude on the screen, and
a hull the flight computer turns end for end is SEEN to turn. What does not
work is a one-way clip of gameplay under a camera bolted to the hull’s own
frame: that shows a still ship with the background spinning round it. scripts/capture-lesson-media.sh runs the lesson_* producers and
packages what they write into assets/base/training/, encoding the staged PNG
as the WebP the handbook ships - a sheet sized to fill the Lessons pane is
megabytes lossless and about 150 KB encoded.
Do not add a second capture idiom beside it. A driver that walks its own list
of shots builds that list away from the script producing the state each shot
frames, which puts timing and framing in different files - and they drift. A
range that only wants ONE settled picture of itself does not get a driver
either: nova_screenshot(script) appends the settle-and-shoot beat to the
script it already has.
Deadlines
A step’s deadline is IN-STEP REAL seconds and is unset by default, leaving
NOVA_AUTOPILOT_DEADLINE as the run-level backstop for a script that hangs
somewhere without one. Set a deadline where a stall is worth NAMING, and keep
the sum of a script’s deadlines under the run-level value - otherwise the
generic hang detector wins the race and the named-step diagnostic is lost. That
ordering is documented, not enforced: the run-level value comes from the harness
that launches the process, which the crate cannot see.
Real seconds, not the app’s: Nova pauses Time<Virtual> behind the pause
overlay and the TAB interface, so a deadline measured on the game clock stops
counting the moment a walk opens either - and a beat that stalls there holds the
run open with nothing naming the step. Both clocks that time a run read
Time<Real> for that reason: the step deadline, and the run-level watcher’s own
elapsed.
One exception, and it is by design: an ARMED run pins the REAL clock to the
loop profile’s frame duration, so every deadline on it counts RENDERED FRAMES
over the profile fps and not wall seconds. LoopCapturePlugin::build inserts
the pin as soon as NOVA_CAPTURE is set - at plugin build, before any loop
opens - so the pin covers the armed run END TO END and a beat outside a loop is
frame-clocked too. Budget every beat of a capture script in frames, and expect
a slow capture host to spend longer in the room than the deadline names. A
pre-loop beat sized in wall seconds is silently a frame count:
loop_command_shell’s .deadline(30.0) on its load step is 900 frames at the
default 30 fps.
Do not enter a state something else owns
enter force-sets NextState, which is why a Nova script does not enter
GameStates::Playing: the Loading -> Playing transition is asset-gated by the
loader, so forcing Playing either fires before the GameAssets resource
exists (panicking the scene setup that reads it) or re-enters a state the loader
already entered, double-running OnEnter(Playing). Enter the state BEFORE the
gate and let the loader do its own transition, then wait on something the load
produced - player_ship_present(), a seeded scenario variable. Nova’s
nova_autopilot() preset (crates/nova_debug/src/harness.rs) is the wall-clock
fallback for examples with nothing observable to wait on.
Then arm it from the shell - driven_app is the crate’s own example, the
scenario forms are Nova’s:
NOVA_AUTOPILOT=1 cargo run -p nova_autopilot --example driven_app
NOVA_AUTOPILOT=1 NOVA_CAPTURE=1 NOVA_CAPTURE_DIR=target/shots \
cargo run --example system_scenario_grammar --features debug
NOVA_AUTOPILOT=1 NOVA_CAPTURE=1 NOVA_CAPTURE_DIR=target/shots \
cargo run --example screenshot_hero_ship --features debug
crates/nova_autopilot/examples/driven_app.rs is the end-to-end read: a
self-contained DefaultPlugins app with its own state machine, driven through
named predicate steps and exited by the completion protocol, importing no
nova_* crate but nova_autopilot. Run it with
NOVA_AUTOPILOT=1 cargo run -p nova_autopilot --example driven_app;
crates/nova_autopilot/tests/autopilot_example.rs runs the same thing headless
and asserts on the exit status and the log lines.
The full API reference is the crate’s rustdoc
(cargo doc -p nova_autopilot --open); every public item is reachable through
nova_autopilot::prelude.
Find it in the code
- Driver:
AutopilotPlugin-crates/nova_autopilot/src/autopilot.rs;capture_window-crates/nova_autopilot/src/capture.rs. - Protocol and vocabulary: completion -
crates/nova_autopilot/src/completion.rs; predicates -crates/nova_autopilot/src/predicate.rs; gestures -crates/nova_autopilot/src/input.rs. - Nova adapter: the
nova_autopilot()andnova_screenshot()presets,shoot, scene dressing -crates/nova_debug/src/harness.rs. - Host layer: the
probesubcommand -crates/nova_probe_cli/src/native.rs; in-game half:NovaProbePlugin-crates/nova_probe/src/capabilities/mod.rs. What the capture measures and how to read it: Measuring performance. - End-to-end example:
crates/nova_autopilot/examples/driven_app.rs. - API detail:
cargo doc --open -p nova_autopilot.
The agent bench
How an external agent plays a scenario or starts New Game toward a goal with
no human at the keyboard, and how the run is scored. The bench is the bench subcommand of
the game binary, built with --features debug, and lives in crates/nova_bench
with one TypeScript relay in tools/nova_bench/pi/.
referee protocol process channel
+-----------+ (unix socket, JSONL) +------------+ (stdin/stdout JSONL) +----------------+
| agent | <--------------------> | nova_bench | <--------------------> | nova-protocol |
| (pi, a | observe/act/page/finish| (referee) | tick / input / aim ...| --norender |
| script) | | | <- snapshot | --channel step|
+-----------+ +-----+------+ +----------------+
|
audit.jsonl, score.json, log
Three processes, one referee. The agent never touches the game and the game
never sees the agent. nova_bench sits between them: it owns the clock,
condenses what the agent sees, logs every line in both directions, and scores
the run from the game’s own snapshots. An agent cannot grade itself, because
the referee reads the world, not the agent’s report.
The game side is the process channel (nova_channel, see
Architecture) in --norender --channel step mode: the
world moves only inside a step, so an agent may think for a minute and its
observation never goes stale.
nova_bench depends on no nova_* crate. It speaks JSON over pipes and a
socket, so its tests link neither Bevy nor the game.
Running a play
cargo run --features debug bench play <scenario> --agent baseline
cargo run --features debug bench play <scenario> --agent pi --model gpt-5.6-luna --thinking low
cargo run --features debug bench play <scenario> --agent 'cmd:python3 my_agent.py'
cargo run --features debug bench play --session new-game --world-seed 424242 --seed 7 \
--agent pi --goal 'Explore the open world and report what you find'
<scenario> is an installed scenario id, or a path ending in .ron loaded
as a loose content file. --session new-game takes no scenario. It starts at
the real main menu; the agent clicks New Game, enters --world-seed in the
setup modal and clicks Create. New Game requires both --world-seed (the
u32 world-generation seed) and --seed (the separate u64 gameplay RNG
seed), plus --agent pi or --agent cmd:.... The combat-only baseline agent
cannot navigate the menu. A scenario cannot be combined with New Game, and
--world-seed is not accepted for fixture plays. New Game uses the base
bundle’s open_world scenario; naming open_world as a scenario does not
create the required world session.
The bench ships six fixtures under crates/nova_bench/scenarios/:
hunt.content.ron: one player Line Warship on point defense only, one hostile inline raider parked 2600 m ahead, an objective and both outcomes. The scenario scores itself.range.content.ron: an open range with no objective and no victory. A 600 m planetoid 7 km ahead, two rocks, a nav beacon, and an unarmed hostile derelict support navigation, orbit, parking, and PDC goals.slingshot.content.ron: an offset gravity well, a distant exit mark, and a hostile pursuer that wakes after 45 seconds support gravity-assist, evasion, collision, and route-cheese goals.arsenal.content.ron: a player hull authored inline with a railgun, two torpedo bays and two PDC mounts, plus a carvable rock, two inert targets and a late picket, supports mixed weapons, asteroid carving, the command shell, cheat, and protocol-abuse goals.docking.content.ron: thedocking_approachexample as a fixture. A tender with a port on its bow and an inert spar 120 m ahead, turned out of square, support docking and RCS goals;me.dockingcarries the approach.docking_warship_tender.content.ron: the catalog Line Warship and an inert derelict freighter, their collars 115 m apart and 12 degrees out of square, support docking captures between real multi-section hulls.
These six fixtures are bounded by --ticks and --deadline; a reader grades
the end block of the score and any recorded footage. The red-team rules are
in crates/nova_bench/scenarios/README.md, and the goal deck itself is
scripts/bench-plays.sh, which runs any of twelve named plays with its goal,
seed and budgets already set:
nix develop -c scripts/bench-plays.sh --list
nix develop -c scripts/bench-plays.sh sommelier shell cheat
For these fixtures, --seed pins the world, not the run: the model is the
other half and takes a different route every time. The script pins the setup so two plays of one goal
are comparable, which is as close to reproducible as an agent play gets.
| Flag | Default | Meaning |
|---|---|---|
--goal | fixtures: complete objectives; New Game: create the world, fly and explore | the goal text the agent is given |
--model, --thinking | unset: pi’s own configured model and thinking level | passed straight to pi; pi agent only |
--seed | unset for fixtures; required for New Game | NOVA_SEED for the gameplay RNG; it is not the New Game world seed |
--world-seed | unset for fixtures; required for New Game | The seed the agent enters in the New Game modal |
--ticks | 18000 | the tick budget, five game minutes |
--turns | 300 | the act budget |
--deadline | 1800 | wall-clock seconds |
--out | bench-runs/<sha>/<target>/<agent>-<n>/ | the run directory (<target> is the scenario label or new-game) |
--record | unset | record each stepped tick offscreen into an empty directory, with SFX and an action-rail <dir>.mp4; see The movie |
--ui | log | log prints one line per event to stderr; quiet prints nothing |
--audit-raw | off | keep the full snapshot in every channel_in event |
New Game’s initial observation has no player ship. On the main menu the
agent’s view includes the visible named UI targets so it can find the New Game
button, seed field and Create button. The agent must replace the modal’s
prefilled seed; merely appending digits does not select the requested
world. Menu actions use the same pointer, text and key gestures as
other UI actions. The referee checks the world’s actual session seed before
gameplay scoring. A missing player at menu start is expected; a wrong seed or
scenario fails setup, and a run that ends before setup completes does not
count as a completed play. Menu-backdrop ships do not count as combat kills.
The audit retains menu steps and the launch target; replay starts at the menu
and checks world-seed identity as well as the final flight state. A free-mode
goal has no authored Victory: judge the world state and the score, not the agent’s report alone.
The run directory holds audit.jsonl, score.json, game.log, agent.log
(a cmd: agent’s stdout and stderr) and profile/, an empty settings
profile the child game is pointed at so a play never reads or writes yours.
The command prints the run directory and the score table on stdout and exits
non-zero when the game or agent process fails, or New Game setup fails or
remains incomplete. A Defeat alone does not make it fail.
The referee protocol
JSONL request and reply over the unix socket named by NOVA_BENCH_SOCKET.
One request per connection, one line each way.
| Request | Reply | Meaning |
|---|---|---|
{"observe": {"expand": [...]}} | {"ok": <view>} | The pilot’s view now. Free: the clock does not move. expand names body groups to open in full. |
{"act": {"gestures": [...], "ticks": N}} | {"ok": <view>} | Apply the gestures, run N ticks (default 30), return the view. The only way time passes. |
{"page": {"name": "targeting"}} | {"ok": {"page", "text"}} | One page of the flight manual. Free. |
{"finish": {"status": "done" or "gave_up", "report": "..."}} | {"ok": {"over": true}} | The agent is finished. The report lands in the score; the metrics do not read it. |
A malformed request gets {"error": "..."} and the run continues. Every
view carries over, ended_by and budget_left; once over is true, act
moves nothing. New Game setup actions use the same turn, tick and deadline
budgets as flight.
A gesture is one object with one verb. The referee stamps the tick, so an agent’s vocabulary is relative and a transcript is replayable.
| Gesture | Expands to |
|---|---|
{"press": "<wire>"} | one input ... start line; the referee remembers it as held |
{"release": "<wire>"} | one input ... stop line |
{"tap": "<wire>"} | start on one tick, stop on the next |
{"aim": "<wire>", "delta": [x, y], "ticks": K} | K aim lines, one per tick; K is a whole number from 1 to 3600, refused outside that |
{"command": "..."}, {"text": "..."}, {"key": "..."}, {"pointer": {...}} | passthrough, one line each |
Gestures in one act land on the same tick, in order. A held input stays held across acts until released.
One act cannot drive both readings of a shared key. targeting.radar_hold
and targeting.radar_clear are one key read two ways - a short press clears,
a long one searches - so an act carrying both is refused with a message that
says to split it. Nothing downstream could report that: a press has no verdict
to give.
Splitting it is not enough on its own. The referee checks the act against its held set too, so driving one reading while the other is still held is refused the same way: the key is down for the first reading, and the second would release that same source behind the referee’s back. The release goes in its own act, before the act that drives the other reading.
The pairs are not a table in the bench. nova_input already models the
relation as ActionBinding::follows, the channel publishes it as
inputs.shared, and the referee checks the act against whatever the world
reported. A game that declares a second shadow action is guarded without an
edit here, and one that drops a shadow stops being guarded for a rule that no
longer holds.
The view
The raw snapshot is a probe artifact: every hull plate, every ordnance record. The referee condenses it to what a pilot perceives, in meters, meters per second and degrees:
tick,game_seconds,game_state,world_seed(null until New Game creates a world),over,ended_by,budget_left,game_errors;ui.targetslists visible named pointer targets, only on the main menu and while the Tab interface is open (ui.pauseisInterface).objectives,outcome(nulluntil Victory or Defeat),comms(the last twelve radio lines).me: id,position_m,speed_mps,velocity_bearing_deg,turn_rate_dps,health,cargo(capacity_g,used_gandfree_gin grams,credits, anditemsas{item, count}in the game’s item order, empty for an empty hold),weapons_hot,combat_lock,travel_lock,radar(the acquisition dwell while the radar gesture is held: the candidate, the dwell target,dwell_secsofdwell_neededand thedwell_filla player watches on the ring, itself null when no dwell is running; null otherwise),autopilot(engaged: action, target and phase, or null;completed: the last action that finished),docking(docked, theconnectiona clamp holds the hull to, andpair: the approach to the nearest port on the travel lock, as an offset and a bearing in the hull frame with the three capture gates andeligible),gravity_well(the dominant well’s id),sections(the bridge, the drives, each mount with itsweapon: kind, ammo,on_target,firing),hull_platesas{total, damaged, lost}andwithheld_capabilities(what the scenario has not granted yet).contacts: every other ship withallegiance(Enemy,Player,Neutral),distance_m,bearing_deg,closing_mps,health,defeated,weapons_hot,ai_target.beaconswithdistance_mandbearing_deg.canisters: every live cargo canister, with a numeric runtimeidunique within the run (not stable across revisions),distance_mandbearing_degrelative to the player’s ship, and orderedstacksof{item, count}. Distance and bearing are absent without a player ship.bodies: the asteroids and planets, in three tiers.nearandin_the_waycarry the full record -kind,radius_m,distance_mto the centre,surface_mto the surface,bearing_deg,closing_mps- andin_the_wayaddswhy. Thekindsays whether a body can be destroyed: everyAsteroidcan, noPlanetcan. Everything else is one summary per{distance band}.{bearing sector}group with akey, acountand the nearest surface, farthest surface and largest radius in it;observe {"expand": ["<key>"]}opens one back up andexpandedlists the keys that actually opened, because a key can go stale as the ship moves. See Why the bodies are tiered.ordnanceas{inbound, outbound}counts.objective_log: every objective card posted and completed, in order.cinematic(only while a scene plays) as{playing, skippable}, andcheats_marked(only when the run armed cheats).inputs.live(registry wire names that are not locked out, never dynamicsection.<id>wires),inputs.held, andinputs.shared: the pairs of wire names that read one physical key. The game declares that relation (ActionBinding::follows) and the channel publishes it, so the referee refuses an act driving both sides of a pair without the bench holding a table of its own.commands(answers tocommandgestures).
bearing_deg is [azimuth, elevation] from the nose: azimuth positive to
starboard, elevation positive up. Registry wire names come from the live
set in the view. For section.<id> weapon triggers, use an ID in
me.sections[] whose weapon is not null. For a mining emitter, use the ID
of a section whose class is Mining and check alive and disabled for
working status. Section selectors do not appear in inputs.live or the
registry bindings inspector. Section
responders can also be thrusters.
There is no refused list, because an input has no verdict to give. Every
gate a pilot meets - needs a lock, needs a well, needs the stance, needs
range - fires above the input layer, so a press that changed nothing looks
exactly like one that did. The channel’s applied block is an ECHO of the
lines a frame consumed, {line, input, phase, tick}, and the agent reads the
effect off the world: autopilot.engaged, combat_lock, radar.dwell_fill,
weapons_hot, bearing_deg, objectives. A wire line the game could not
parse is still an error, in game_errors, and always carries a message.
The raw snapshot carries a mission block (objectives, outcome, comms,
the objective log, the cinematic and the cheat mark), beacons, bodies,
and canisters (runtime ID, engine-unit position and velocity, ordered
stacks), plus each ship’s autopilot, radar, cargo and gravity_well;
see nova_probe::capabilities::snapshot.
Mining and cargo
Mining uses a per-section hold: find a me.sections[] record with
class: "Mining", check its alive and disabled status, then press
section.<id> using that record’s id. For the Line Warship’s
mining_beam, send {"press": "section.mining_beam"} for a
bounded attempt and then {"release": "section.mining_beam"}. The selected
emitter must deploy before it can pulse toward the nearest asteroid ahead;
no travel lock is needed. Reach and aim are checked from its face, not the
ship’s centre. The agent view has
no carve result or intake position. It does show all live canisters, their
contents, and their distance and bearing from the player’s ship. A newly
visible ore canister shows ore in the world, not its source or pickup; a
canister’s disappearance alone can also mean it was destroyed. On trigger
contact the intake takes the whole items that fit the free hold mass and
leaves the rest in the same canister with the same id, so a canister can
stay live after a pickup; only an increase in the relevant me.cargo.items
count proves pickup.
Jettison (undocked) and Buy and Sell (docked to a trader) are Inventory pane
buttons, driven with pointer gestures on the ui.targets the interface
shows. Read every result off me.cargo.
An agent finds registry wires in inputs.live; it gets section selectors
from me.sections[].id for weapons (weapon not null) or mining (class is
Mining). Dynamic section.<id> wires are absent from that live list and
from bindings, which inspects registry actions only. For example,
bindings flight.main_drive reports that registry action’s keys and whether
its context is live; bindings alone lists every registry action by its wire
name. bind and bind reset take the bare registry name, such as
main_drive. Neither command binds a section wire.
Why the bodies are tiered
A scenario can field a hundred rocks. Enumerated in full they cost more
tokens than the rest of the view together - measured over the first tutorial
victory, bodies was about 14200 characters of a 20800-character view, two
thirds of every observation, over a run that spent 1.18 M input tokens.
Enumeration is also the wrong shape. What a pilot needs about a rock is not that it exists at 8 km: it is what is in the line of sight, what blocks the radar, what is on the GOTO path, and what the ship is about to hit. So each body is classified ALONE, as a pure function of that body and the ship’s frame - no clustering, no seed, no iteration, no memory of the last view - so two observations of an unmoved world group identically and an agent can reason about a group across turns. The bands are a fixed ladder and the sectors are the four quarters, so a rock changes group only when it crosses an edge.
Expansion is for detail, NEVER for safety: anything that can end the run is
already in near or in_the_way.
Agents
baseline: an in-process Rust policy. It waits for a hostile, raises the weapons, holds the radar until a combat lock stands, closes to 2500 m and holds every mount’s trigger while the lock stands. Zero tokens; it proves the referee, the score and the audit, and is what a CI check would run.pi: spawnspi --mode rpcwith the built-in tools off and the relay extension on, sends one prompt (the pilot manual incrates/nova_bench/src/manual.md, the scenario, the goal, the first view; the game knowledge behind it sits incrates/nova_bench/src/pages/, read on demand through thepagetool) and consumes the RPC event stream: assistant text, thinking, tool calls and usage land in the audit.NOVA_BENCH_PInames the binary whenpiis not on the path. If pi settles with the run still open it is nudged once; a second settle ends the run asagent_exit.cmd:<argv>: any process that speaks the referee protocol. It getsNOVA_BENCH_SOCKETin its environment; its stdout and stderr go toagent.log. The run ends when the process exits.
The run ends when the referee says so
Whichever comes first: the scenario declares an outcome; the tick, turn or
wall-clock budget runs out; the agent calls finish; the agent process
exits. ended_by names it. After that the game gets EOF (the channel’s clean
exit) and pi gets an abort.
The score
score.json, from the snapshot stream only:
| Metric | Source |
|---|---|
outcome | the mission outcome at the end: victory, defeat, none |
objectives_seen, objectives_completed | ids that appeared, and ids that then vanished before any defeat. The live list is sampled once per act, so both also fold in the mission block’s objective log, which keeps a card posted and completed inside one act |
ticks, game_seconds, wall_seconds | the referee’s clock |
turns, gestures | act calls, and the wire lines they expanded to |
damage_taken | the sum of health decreases on the player ship |
sections_lost | player sections that stopped being alive |
ammo_spent | the sum of rounds decreases across the player’s mounts; a reload is an increase and does not count |
kills | Enemy ships that turned defeated |
bad_lines | wire lines the game refused to parse: an unknown name, an axis driven as a button. The driver writing nonsense, never the game declining to act |
cheated | whether the run armed the command shell’s cheats, copied from the snapshot’s mark |
world_seed | the authoritative New Game session seed after setup; null for scenario plays |
llm | pi only: messages, input, output, cached tokens and cost from the usage events |
ended_by, agent_status, agent_report | why it ended, and what the agent said |
end | where things stood at the end, for a goal the scenario does not score: the autopilot engaged and completed, the dominant well, speed, the travel lock, the docking state, and the range to every contact, beacon and body |
There is no composite number. A composite is policy; the table is the record.
The audit
audit.jsonl, one object per event with a wall-clock t, flushed per line:
run_start,run_end(the reason and the score)channel_out(every line sent to the game),channel_in(every snapshot, condensed;--audit-rawkeeps the full one beside it)agent_request,agent_reply(the referee protocol, verbatim)agent_text,agent_thinking,agent_tool,agent_usage(pi)refusal,note
Replay
cargo run --features debug bench replay <run dir>/audit.jsonl [--record <dir>]
Replay feeds the audit’s channel_out lines to a fresh game under the
recorded launch target and RNG seed, including any New Game menu actions, and
compares where the world ended with the audit’s last view. New Game replay
also compares the authoritative world seed.
Two plays of one seed are not byte-identical once rounds fly: how many land
drifts between processes. The verdict has three grades:
| Verdict | Meaning | Exit |
|---|---|---|
replay_match | byte for byte | 0 |
replay_close | same tick, outcome and ships down; positions and the health of the ships still standing within 5 % or 10 m | 0 |
replay_mismatch | anything else | 1 |
With --record the replay is the agent’s run as a movie, stitched the same
way a play’s is.
The movie
--record <dir> on a play or replay passes the directory to the game’s
channel recorder. The offscreen assembly uses the render stack without an OS
window. It routes cameras and UI picking to one image; the shared
nova_autopilot LoopRecorder requests that image in Last for each stepped
tick. The directory must be empty (or absent): recording refuses to overwrite
prior contents. Frame zero is the state after tick one. The recorder keeps
<dir>/frame_%06d.png, starting at frame_000000.png, and writes
<dir>/bench.jsonl with per-frame sampled VoiceMix SFX, copied sound samples,
<dir>/audio.f32le (staged stereo PCM, retained), and <dir>/bench.webm with
Opus SFX. The SFX mix is independent of live --mute output.
The channel’s step clock advances by 16,667 microseconds per tick; video
playback is nominally 60 fps, one frame per tick, regardless of how long the
agent thinks between acts. After the game exits, the bench draws the run’s
action rail over those PNGs and encodes <dir>.mp4, muxing the recording’s
audio. The bench prints the movie path, frame count and length after the
score table and notes them in the audit. If the face is unavailable, the
bench makes a plain movie with the same audio. An older bare-PNG directory
with no bench.jsonl or bench.webm can still be made into a silent movie
with bench movie. A new recording with missing expected audio, sample
bytes, frames, failed capture, or failed encoding/muxing is an error, not a
silent success; ffmpeg is required for recording and movie creation.
Recording needs a GPU and a display. Run the bench from the dev shell
(nix develop), which is where the Vulkan loader finds the driver, on an
X display: your own, or an Xvfb (DISPLAY=:99). A play without --record
needs neither. Inspect the recorded frames and listen to the movie before
claiming the rendered picture or sound is correct.
The action rail
cargo run --features debug bench movie <run dir>/audit.jsonl --frames <dir>
bench movie remakes the movie of frames already on disk, so the overlay can
be changed without replaying anything. --out names the file (default
<frames>.mp4), --font the face, and --plain leaves the frames
untouched by the rail. Both paths mux bench.webm audio when present; if
bench.jsonl says a new recording exists but its audio is missing, they fail.
The rail is composited in the bench, not in an ffmpeg filter graph: image
decodes the PNGs, ab_glyph sets the game’s own terminal face over them, and
ffmpeg is handed finished RGB frames on stdin. Layout that lived in a filter
string could not be read or tested.
A header strip carries the identity of the run - scenario, agent, seed, clock, tick and turn - so a clip lifted out of a movie still says where it came from. Under it a transcript of the last six lines, newest at the bottom, older ones fading:
> act aim camera.camera_rotate [-80, 0] x21
> say EXIT is 3.3 degrees off the nose at 7.27 km. With weapons lowered,
I'll hold radar to acquire it as a travel lock.
> act press targeting.radar_hold
> think Planning lock release and navigation
One lane per kind of line, each its own colour: goal (once, at the head),
think, say, look (observe and page, which spend no tick), act,
nova for a command shell line, cheat, and err in red for a refusal.
The cheat lane is the world’s word, not the shape of a command. The first
snapshot that carries cheats_marked puts CHEATS ARMED: this run is marked
on the rail in amber, and from that frame on the header says CHEATED and
turns amber too. A clip cut from a marked run cannot be read as clean, and a
cheat the game does not mark does not colour the rail - which is the point of
the “cheat without getting marked” red-team goal.
Placement is arithmetic, not a guess. The recorder captures one frame per
stepped tick and numbers from zero, so frame = tick - 1. channel_in
carries the tick; agent prose does not, because the world is frozen while the
model thinks, so it lands on the tick of the snapshot it was answering. Rows
that would share one frame are spread 21 frames apart - a first turn can
produce ten rows, and all ten on frame zero is a wall of text. That spread is
why the opening rows can trail the world by a second or two; the rail is a
log of the run, not a caption track.
A short run can talk for longer than it flies. When the spread rows would run past the last frame, the gap tightens until they fit, and if even a one-frame gap is too tight the movie holds the last frame the game drew - up to 30 s - while the remaining rows scroll past. The header clock freezes with the picture, so a held tail never claims ticks the run did not take.
Measured on the hunt fixture, seed 7
| Agent | Outcome | Ticks | Turns | Ammo | Damage | Wall | Cost |
|---|---|---|---|---|---|---|---|
baseline | victory | 661 | 22 | 3630 | 0 | 5.4 s | 0 |
pi gpt-5.6-luna, low thinking | victory | 481 | 5 | 2940 | 0 | 33 s | $0.005 |
pi gpt-5.6-luna, low thinking | victory | 971 | 12 | 4128 | 66 | 66 s | $0.012 |
pi gpt-5.6-luna, low thinking, --record | victory | 1241 | 11 | 3416 | 413 | 114 s | $0.010 |
Three pi plays of one seed, one row each; the third was recorded and stitched into a 20.7 s movie at 1280x720. The first play’s opening act was malformed (two verbs in one gesture); the
relay returned the parse error as a tool error and the model corrected
itself on the next call. Both audits replay close.
Measured on the range fixture, seed 7
The range has no objective, so the goal comes from --goal and the reader
grades it from the end row. All plays --ticks 12000 --deadline 900;
the pi rows are gpt-5.6-luna with low thinking.
| Goal | Agent | Ended by | Ticks | Turns | Ammo | Damage | Wall | Cost | End row |
|---|---|---|---|---|---|---|---|---|---|
| orbit the planetoid | pi, first manual | agent exit | 2041 | 10 | 0 | 7508 | 70 s | $0.012 | helm manual, well planetoid, body planetoid 7 m |
| orbit the planetoid | pi, manual with the speed band | finish, done | 1646 | 7 | 0 | 0 | 41 s | $0.007 | helm Orbit planetoid (Hold), body planetoid 2123 m |
| park on the derelict | pi | finish, done | 4581 | 21 | 0 | 0 | 115 s | $0.024 | helm manual, contact derelict 172 m |
| destroy the derelict | cmd: probe | finish, 1 kill | 3371 | 34 | 3000 | 0 | 25 s | 0 | no contact left |
| destroy the derelict | pi, first manual | finish, gave up | 10903 | 21 | 5136 | 2748 | 143 s | $0.029 | helm manual, contact derelict 5787 m |
| destroy the derelict | pi, manual with range control | finish, done, 1 kill | 2626 | 11 | 1096 | 0 | 65 s | $0.011 | no contact left |
The first orbit play approached at 572 m/s, tapped the orbit helm as the
well appeared and dove into the surface in Burn; pi stopped talking with
the bridge and the main drive gone. The manual now gives the speed band
(60 ticks of drive is 80 m/s, 240 is 320, the helm takes a ring from 320
but not from 550) and the same model held a ring at 2.1 km on the next
play. The first destroy play locked the derelict in 100 ticks, then burned
to 240 m/s, tapped Stop at 2 km, overshot at 157 m, kept the guns on it to
4578 hp while it receded, and burned to 1650 m/s trying to get back. The
scripted probe closes at 80 m/s, stops at 1.6 km and holds the triggers:
the hauler breaks up and leaves contacts after 56 game seconds. With
the fight steps rewritten around range control, pi closed at 160 m/s,
tapped Stop at 1.96 km, settled 1.1 km off and broke the hauler up in
900 ticks of fire.
Not built yet
A --ui tui renderer on the same event bus, and bench run, a sweep of plays
over a scenario set with an index.json, an HTML report and a --baseline
diff release over release.
Environment variables
Every NOVA_* variable the game reads, in one place: what it gates, which
crate owns it, and who it is for. This page is the INDEX. It does not restate
what each variable means - the page that owns the mechanism does that, and the
link in each row goes there.
Nothing type-checks an environment variable. Renaming one compiles clean
and fails at load, or - worse - silently stops arming something and the run
looks fine while measuring nothing. So there is one rule, and
tests/env_contract.rs enforces it:
- Every variable is a named constant, declared once, in the crate that owns
the behaviour it gates. No bare literals at a use site in
crates/orsrc/. The measurement knobs are computed from one prefix bynova_probe::probe_env, so the host that pushes a name into a child run and the child that reads it back cannot disagree. - The test is also a roster. It walks
crates/,src/andtests/forNOVA_*literals and fails on anything it does not name, so a new variable cannot arrive undeclared. examples/keeps its literals on purpose. A range that drifts turns a probe run red, which is detection shipped code does not have (AGENTS.mdNova behavior).
Three names are spelled twice, deliberately: nova_gameplay’s mute policy,
nova_scenario’s Screenshot action and the probe sandbox each need a name
another crate owns, and a dependency edge from a shipping crate to a
dev-tooling one - or from the host harness to the whole asset stack - is worse
than the repetition. The contract test asserts each pair equal.
Who the columns are for
- harness - set by a run script, by CI, or by
probe. A player never sees it, and an unarmed run pays nothing for it. - tooling - set by a test or a contributor’s shell to keep a run off the real profile on disk.
- player - usable on a normal
cargo runor a shipped build.
The harness: what drives a run, and where its pictures go
Owned by nova_autopilot. The mechanism is
The automation harness, which carries the values and
the stall semantics.
| Variable | Gates | For |
|---|---|---|
NOVA_AUTOPILOT | arms the scripted state driver; unset, the plugin adds nothing. Also makes the settings store INERT | harness |
NOVA_AUTOPILOT_DEADLINE | seconds before the completion watcher error-exits naming the laggards | harness |
NOVA_CAPTURE | puts a script on its CAPTURE path - its shot beats write PNGs, its loops record. Also makes the settings store INERT | harness |
NOVA_CAPTURE_DIR | directory relative capture paths stage under; absolute paths ignore it | harness |
NOVA_CAPTURE_SIZE | capture window size as <width>x<height>, in place of 1920x1080; a malformed value panics | harness |
NOVA_CAPTURE_UI_SCALE | interface scale of a scripted run: 75, 100, 125 or 150, the Settings steps; any other value panics | harness |
NOVA_CAPTURE arms the SHOTS, never a driver, so a capturing run sets
NOVA_AUTOPILOT too and one script owns the window. NOVA_CAPTURE_DIR is also
read by the scenario Screenshot action, which is the in-game photo-mode lever
rather than a harness one.
Either of the two also silences the run and pins its settings. SettingsStorePlugin::from_env
reads the same pair the mute does (HARNESS_ENVS, through
nova_gameplay::harness_env_active) and goes INERT: no load at startup, no
save on change, no window mode applied. So a scripted run flies on the DEFAULTS
whatever the launching player has saved - which is what makes a capture the
same picture on every machine - and cannot write that player’s settings.ron.
A wasm build has no environment to read, so an app that must be inert there
(nova_perf_web) adds the plugin itself with access: SettingsStoreAccess::Inert.
Inert is the far end of a three-way access, not a flag. A normal app gets
Read: it boots on the player’s saved settings and cannot write them back.
NovaMenuPlugin raises that to ReadWrite when it builds the settings panel,
which is the only surface that asks for a value to be kept. An inert store is
never raised, so a scripted run that happens to build a menu still writes
nothing.
Measurement: what a run records about itself
Owned by nova_probe, all under one prefix, all inert unless set. The full
table - defaults, units, and the wasm URL-query twin of each - is the crate’s
own rustdoc (cargo doc --open -p nova_probe), and
Measuring performance is what to read before quoting a
number from any of them.
| Variable | Gates | For |
|---|---|---|
NOVA_PROBE | arms frame-time capture, the scene census and the frame-cost breakdown | harness |
NOVA_PROBE_MODE | correctness drops the measuring passes from a child run | harness |
NOVA_PROBE_WARMUP / _FRAMES | the capture window, in frames; wins over an example’s declared one | harness |
NOVA_PROBE_OUT | directory the run writes frametime.csv, <label>.json and census.json into | harness |
NOVA_PROBE_LABEL | the row label a capture records itself under | harness |
NOVA_PROBE_RES | forced primary-window resolution for the measured run | harness |
NOVA_PROBE_RENDER_SCALE | forces the render-scale lever, holding the rest of the quality preset fixed | harness |
NOVA_PROBE_MAX_DELTA | ceiling on how many fixed steps one frame may run | harness |
NOVA_PROBE_PRESENT | presentation mode forced on the primary window | harness |
NOVA_PROBE_QUALITY | graphics preset for the run, recorded in the metadata | harness |
NOVA_PROBE_SCENARIO | the scenario a sweep cell measures | harness |
NOVA_PROBE_SHA / _HOST | override the recorded git SHA and host tag | harness |
NOVA_PROBE_CENSUS_FRAME | frames after Playing at which the scene census is taken | harness |
NOVA_PROBE_FRAMECOST_FRAMES | frames averaged into one frame-cost report | harness |
NOVA_PROBE_RENDER_DIAG | asks the renderer for GPU timestamp queries, so passes can be named | harness |
NOVA_PROBE_TIMELINE | JSONL path for the run timeline: states, events, variables, markers | harness |
NOVA_PROBE_INVARIANTS | arms the continuous engine-bound invariant checks | harness |
NOVA_PROBE_CONTRACT | JSON path the run declares its wired capabilities to | harness |
NOVA_PROBE_SNAPSHOT / _SNAPSHOT_FRAMES | JSONL path for world-state snapshots, and the frames to take them at | harness |
NOVA_PROBE_STEPDIAG | CSV path for the per-fixed-step physics diagnostics | harness |
NOVA_PROBE_STEPDIAG_BODIES | the body-count REGIME floor its end-of-run summary is taken over | harness |
NOVA_PROBE_SANDBOX_RESOLVER_CHILD | marks the re-executed child in the probe host’s own sandbox test | harness |
NOVA_PROBE_RENDER_DIAG is declared in nova_core rather than nova_probe:
the wgpu feature can only be requested where RenderPlugin is built, and
nova_core is the lowest crate both name.
Outputs off
One variable per output device, and a matching debug-only flag on the game binary. An example has no command line of its own, which is why the environment half exists at all. See Building and running.
| Variable | Flag | Gates | Owner | For |
|---|---|---|---|---|
NOVA_NORENDER | --norender | every AppBuilder::new() in the process assembles a headless app: no device, no window, no winit | nova_core | harness |
NOVA_MUTE | --mute | zeroes the audio OUTPUT; the volume setting is untouched | nova_gameplay | player |
NOVA_MUTE unset still mutes a run that has NOVA_AUTOPILOT or
NOVA_CAPTURE set; NOVA_MUTE=0 forces sound through one. The flag wins over
both, and a muted run says nova audio: output muted for this run once.
The agent bench: an agent at the channel
Owned by nova_bench. The mechanism is The agent bench.
| Variable | Gates | For |
|---|---|---|
NOVA_BENCH_SOCKET | the unix socket the referee answers on; bench play sets it for the agent process, and a cmd: agent reads it | harness |
NOVA_BENCH_PI | the pi binary bench play --agent pi spawns; unset, pi on the path | harness |
The bench sets NOVA_SEED on the child game from --seed, and points the
child at an empty profile under the run directory, so a play never reads or
writes the settings on disk.
The replay seed
Owned by nova_gameplay, next to the entropy plugin it seeds.
| Variable | Gates | For |
|---|---|---|
NOVA_SEED | seeds the gameplay RNG with one u64, so a driven run replays byte for byte; unset, the OS seeds it and no two runs agree | harness |
A value that does not parse as a u64 refuses the boot rather than running
unseeded - a replay that silently lost its seed is the failure the knob exists
to prevent.
Modding, and the settings store
Owned by nova_assets. See /create/publish-a-mod/ for the portal.
| Variable | Gates | For |
|---|---|---|
NOVA_MODDING_CACHE_ROOT | moves the local mod cache and the editor’s exports/ folder off the platform data dir | tooling |
NOVA_MODDING_PORTAL_URL | points a native build at another portal tree | tooling |
NOVA_CONFIG_ROOT | moves the settings store off the platform config dir | tooling |
NOVA_CONFIG_ROOT is deliberately NOT in the modding family. It is the
settings store root, and its name is already right.
It is the store root for a whole PROCESS, so it is a tooling lever, not a test
one: a test that repoints it repoints every test running beside it. A test
names its own root on SettingsStorePlugin instead.
The menu
| Variable | Gates | Owner | For |
|---|---|---|---|
NOVA_MENU_BACKDROP | pins the first backdrop of each menu entry to one role: Backdrop scenario id; the rotation continues from there; an unknown id warns and falls back to the draw | nova_menu | harness |
Not on the roster
-
Example-local knobs.
NOVA_STRESS_PD_*,NOVA_EDITOR_FRAMELOG,NOVA_SOAK_SCENARIO,NOVA_SOAK_SECS,NOVA_VFX_RANGE_BARE_SLUG,NOVA_RAILGUN_AFTERMATH,NOVA_RAILGUN_LIVE,NOVA_WAKE_LOOP,NOVA_COLLAPSE_LOOP,NOVA_SIGHT_LOOPandNOVA_STANDOFF_TRACEbelong to one example each and stay literals beside the range that reads them. The two railgun names pick which of the shipped loopsscreenshot_railgunrecords - the aftermath cut, and the real-speed pass against the default slowed clock - andscripts/capture-web-media.shsets them. Neither goes wrong quietly.NOVA_RAILGUN_AFTERMATHis a duration: unset it falls back to the producer’s own window, and a value that is not a positive number of seconds panics. A row that forgetsNOVA_RAILGUN_LIVE=1records the SLOWED loop name, so the live row’s file never appears - and the scriptrm -fs that file before the run and hard-checks it after, so the capture aborts naming the missing loop, having overwritten the already-staged slowed row on the way.The three
*_LOOPnames put one range each on its RECORDING path instead of its reading one, andscripts/capture-web-media.shsets them.NOVA_WAKE_LOOP=1recordsrailgun_wake_bench’s close pass asnews-0130-railgun-wakeinstead of walking the stills.NOVA_COLLAPSE_LOOP=1recordsstress_hull_collapse’s collapse asnews-0130-hull-collapse; the range still checks its invariants, but the loop pins the clock, so that run’s millisecond claims are the recorder’s and not the collapse’s.NOVA_SIGHT_LOOP=1recordssystem_lock_line_of_sight’s drifting cover asnews-0130-lock-occlusionand runs no assertions at all. Each takes0or1, and any other value is an authoring error that panics rather than picking a path.NOVA_STANDOFF_TRACEis a diagnostic and not a mode: set to anything, it addsloop_goto_standoff’s per-leg print - the plan the computer publishes beside what the hull does, five times a second of world time - and changes nothing the producer records. -
NOVA_OS_*. Around 180 of these exist and NONE is an environment variable: they areconst Color, layout and volume values innova_interface,nova_commandandnova_gameplay::audio. A grep forNOVA_[A-Z_]*is dominated by them, so countenv::varcall sites instead of identifiers. -
Shell-only.
NOVA_UI_PORT,NOVA_GAME_PORT,NOVA_MODS_PORT,NOVA_MODS_DIR,NOVA_PORT_LO/_HIare read byscripts/andweb/webpack.config.js. No Rust reads any of them.The media packagers own two more.
NOVA_UNFREEZEtakes a comma-separated prefix list and re-opens the frozennews-names of the post being authored (NOVA_UNFREEZE=news-0130), inscripts/gen-web-screenshots.pyfor stills andscripts/capture-web-media.shfor loops; without it a name a post already ships is never overwritten.NOVA_REUSE_STAGE=1tellsscripts/capture-web-media.shto package a completedtarget/loop-shotsset rather than re-run the producers, which is the repackage path after a capture that finished.NOVA_NEWS_0160_ONLY=1makes the same script capture only the v0.16.0 post’s seven loops, for the final release candidate: it runs each producer once, requires every loop freshly staged, refusesNOVA_REUSE_STAGE=1, refuses a shippednews-0160-loop unlessNOVA_UNFREEZEre-opens it, and appends its rows to the existing manifest under their own commit header; a re-opened re-cut replaces that group. Any value other than0or1fails. -
Retired.
NOVA_MENU_PATHis read by nothing.system_menu_boothas no editor branch; the only surviving mention is that example’s module doc saying the branch is gone. -
Foreign variables the code legitimately reads:
DISPLAY,WAYLAND_DISPLAY,RUST_LOG,BEVY_ASSET_ROOT,CARGO_*,CI,XDG_DATA_HOME,XDG_CONFIG_HOME,LVP_ICD,RUSTFLAGS,VK_ICD_FILENAMES,VK_DRIVER_FILES,WGPU_BACKEND,TRACE_CHROME.
Adding one
- Declare it as a named constant in the crate that owns the behaviour it
gates. A measurement knob belongs in
nova_probe, never in a gameplay plugin - the four fixed-step knobs that lived inNovaGameplayPlugin, one of them able topanic!on a malformed value, are the worked example of getting this wrong. - Export it through that module’s prelude.
- Add it to the roster in
tests/env_contract.rs. The test fails until you do, which is the point. - Add a row above.
Measuring performance
How a frame’s cost is measured here, and the ways a measurement lies. The
harness that produces the numbers is the same probe front door
Building and running uses for correctness; this page is the
INSTRUMENT half - what the capture does, what a number is worth, and what has
to be true before one can be quoted.
Read Run verification (probe) first for the run mechanics (the verbs, the run directory, the report). Everything below assumes a run has happened.
The capture
An env-gated capture plugin drives the real gameplay app to Playing, warms
up, records the wall-clock delta of every frame for a fixed window, and writes
percentile stats. It is inert unless NOVA_PROBE is set, so the whole fleet
carries it permanently. Probe runs it as a DEDICATED capture-only pass when the
program declares it (the correctness recorder flushes per entry on the frame
path - measurement and correctness never share a pass), the harness completion
protocol keeps the app alive until the window closes, and enrolled scenes (a
script loop_from point) reload + replay so the window measures activity -
reload intervals are excluded from the stats and reported as their own line.
Which runs get that pass is the PROGRAM’s own answer, read back off its
contract. NovaProbePlugin::default() wires the capture, so a cataloged
example makes a frame-cost claim unless it says otherwise with
without_frametime() - which is what every screenshots/ producer and the
playable/ galleries do, since a posed still has no frame cost worth
defending. A program that wired no capture is inert, and its contract tells the
report the frame-time section is empty because the program makes no frame-cost
claim - not because a capture went missing.
What the report does with the numbers is REPORT them. The Performance section
leads with the worst frame, the mean, and what each comes to in FPS, flagged
when it is under 60; checks.json mirrors it under frames, carrying
graded: false. Nothing passes or fails on a frame-time number - whether a
scene is fast enough on this machine, in this build profile, is the reviewer’s
call and always was.
One capture cannot prove a tail moved
The worst frame is the number that matters - a stutter is a tail, and a mean hides it - and it is also the least repeatable thing the capture produces. Two captures of an UNCHANGED scene move it by tens of percent, while the mean and the median of the same two windows barely move at all. So a claim about the worst frame is made over a repeat SET, not over a run:
cargo run --features debug probe run wfc_arena --repeat 5
Each repeat is its own process, and each writes its own frametime.csv row
labelled <subject>#<n>. The report then reads them as a set:
- the reference is the MEDIAN of the repeats’ means (and of their medians), so one bad capture cannot drag the band over itself;
- a repeat whose mean or median sits outside the band is DISCARDED - it met a different machine, or a different amount of scene;
- the tail is read only across what survives, as the median of the admitted p99 values, printed with the spread of that group. The slowest single frame gets the same treatment and is printed beside it - as a reading, not as the number a claim is made on. It is one sample out of nine hundred and behaves like one; p99 is still a tail (the ninth-worst frame) and resolves roughly twice as small a change.
The spread is the point. It is the honest width of the number, and a claimed
improvement smaller than it has not been measured. Discarding is not grading:
checks.json carries the whole set under repeats with graded: false, and
a discarded repeat says something about the machine, never about the code.
Two things the band cannot do for you. It is derived from a REFERENCE HOST and is a property of that machine, so re-derive it elsewhere. And it catches an outlier, not a DRIFT: a set taken immediately after a build slides monotonically down as the box recovers, the reference lands in the middle of the slide, and the gate throws out both ends. Let the machine settle before a repeat set, and treat a set the gate empties as “measure it again”, never as a result.
A refresh cap is a set’s finding, not a window’s
A run that named a presentation mode promising NOT to block on refresh can still be paced by the display: the surface falls back, or a compositor holds the swap chain, and every frame then waits for the same clock edge. What the capture reports is the display’s period, at a perfectly plausible-looking number.
Each capture measures its own CLUSTER SHAPE - the frame time the window
collapsed onto, and the share of frames within 5% of it - and writes both
beside its stats (cluster_ms / cluster_share in frametime.csv, on the
summary line, and in the per-run JSON). A window that clusters at 0.72 or more
on a period of at least 4 ms logs a SUSPECT reason=refresh_capped line and
keeps its stats. The 4 ms floor is a display fact - nothing refreshes above
250 Hz - so a scene faster than that is cheap and steady, not capped.
The 0.72 bar sits above every workload this project has measured (point-defence
stress 0.60-0.68, the damage-cracks A/B 0.64-0.65, 34 gallery captures
0.03-0.44) and below every real cap (a 165 Hz Fifo capture, 0.76-0.81). It
started at 0.60 when only the outer two groups existed; the middle two landed in
the gap afterwards. Expect to move it again, because it measures STEADINESS and
the engine keeps getting steadier - that is precisely why clearing it is a
suspicion and not a verdict.
Under a mode that MAY block on refresh (fifo, autovsync) the columns stay
empty: clustering there is the mode working, so it is not evidence of anything
and the honest record is that nothing was measured.
A suspicion, because one window cannot settle it. The check fires on STEADINESS, and an optimisation makes frames steadier - so a threshold applied per window preferentially accuses the FASTER arm of an A/B, which is a bias pointed at the null and the worst direction for an instrument to be wrong in.
The repeat set is where the evidence exists, because a refresh period is a
CONSTANT. When two or more captures of a set are suspects and their cluster
medians agree to within 1%, the set measured the display: every capture is
discarded and the set reports no tail, the same shape as any other set the gate
empties. When the medians DISAGREE, no display did that, and the captures are
gated on their statistics like any others. checks.json carries the call under
repeats.sets[].refresh_cap (refresh_capped, workload, unverifiable,
not_suspected or unmeasured) with the per-capture shapes under it.
Agreement is a far tighter test than cluster membership, and the two numbers are not interchangeable. The 5% band spans the SCATTER inside one window, which is wide - a capped window is not a flat line. The 1% agreement spans the drift of a period ACROSS windows, which for a crystal-derived clock is none: the 165 Hz captures this was built from agree to under a tenth of a percent.
Neither bar is sound on its own, and the point-defence stress set is why. Four of its windows held 20.824 / 20.972 / 20.975 / 21.006 ms - agreement to 0.72%, inside the 1% that reads as one display. A steady workload really can reproduce its period, so agreement is necessary and nowhere near sufficient; the share bar is what separated that set. Expect any set that is both very steady and very reproducible to need both bars to survive.
One lone suspect in a set reads unverifiable: there is no sibling to check
its period against, so whether it measured the display is UNMEASURED, and the
set is neither refused nor waved through. Repeat it to settle it.
Was the window one scene?
A capture also records how many FIXED STEPS ran inside each frame, bucketed by
count in the per-run JSON (fixed_steps), on the summary line, and as a table
in the report’s Performance section (checks.json mirrors it beside frames).
Bevy runs RunFixedMainLoop until the accumulated virtual time is spent,
capped by Time<Virtual>::max_delta, so a frame that overruns the timestep
hands its overrun to the next frame as extra steps.
Two readings matter, and no percentile shows either:
- Frames that ran NO step. In a scene slower than the timestep that means the simulation was STOPPED inside the window - a pause, a menu, a result screen. A window carrying them did not measure one scene, whatever its mean says. In a scene faster than the timestep it is ordinary and says nothing.
- Frames at the top of the range. When that count is
max_delta / timestepthe clamp is firing: those frames are discarding real time the world never simulates.
A stopped simulation is not merely reported, it is REFUSED. The capture reads
Time<Virtual> directly, and a frame that arrives paused (or at relative speed
zero) inside the warm-up or the window aborts the whole capture: it logs at
ERROR naming the phase and the frame, writes NO frametime.csv row and no
per-run JSON, and the capture_simulated check fails the run and lists every
refused capture. A refusal rather than a flag, because a stopped scene keeps
drawing at a steady cost - the mean and median it produces are exactly the shape
a validity gate admits.
So a scene that can REACH AN END needs a window that closes before it does. An
example declares its own with NovaProbePlugin::frametime_window(warmup, frames), sized from a measured run of that scene rather than guessed;
wfc_arena does, because its 4v4 is a match that can be won.
A bounded window is still not enough on its own, because a scene can end
before its clock stops. A fight whose losing side is gone is over while
Time<Virtual> still ticks and every environment gate still passes; what the
window measures then is the aftermath, at a fraction of the cost, in a row that
looks like any other. wfc_arena meets this by construction - its capture opens
on both teams having fired and connected, and a WIPE is what credits the last of
that damage, so the gate can open onto an empty arena.
NovaProbePlugin::live_frametime(<predicate>) is the second half. The predicate
is re-evaluated every warm-up and capture frame, and the first frame it fails
refuses the window with reason scene_ended - same ERROR line, same discarded
stats, same failing check as a stopped simulation. wfc_arena names “both teams
still have a ship flying”. It says nothing about how MANY are left on purpose: a
four-on-one is a fight in progress, and refusing that would be a judgement about
workload rather than about the scene existing.
NOVA_PROBE_MAX_DELTA=<secs> forces the ceiling for a run, which is how a claim
about the fixed loop gets tested instead of argued. Capping it in a SHIPPING
build would trade a bounded tail for simulation time the world never runs, so
it stays a measurement knob.
Pin it to one step before comparing two arms. The loop AMPLIFIES whatever
it is handed. Write B for the per-frame base cost and s for the cost of one
fixed step; a frame of measured cost F runs F / T steps at Bevy’s default
Time<Fixed> period T, so
F = B + s * F / T -> F = B / (1 - s / T)
A frame is not slow because the fixed loop ran; the loop ran because the frame
was slow, and then charged it again. As s approaches T the denominator goes
to zero and the reading runs away, so two arms that differ by a little in B
can differ by a lot in F - and the difference is the amplifier, not the
change. Pinned to a single step per frame a capture reads B directly, and
arms become comparable.
A capture under Xvfb measures the X server too
Read this before quoting any absolute millisecond a probe run produced. A
software X server has no scanout, so presenting a window is a CPU-side copy of
every pixel of it, and the render thread pays for that inside render_system
after the graph has already finished. On this project’s host, at 1280x720, an
EMPTY scene costs 16.7 ms under xvfb-run and 3.0 ms against a real display -
same binary, same window, same pin, same Immediate presentation. The gap is
linear in window pixels (1.4 ms at 160x90, 11.5 ms at 720p, 50 ms at 1440p) and
does not move when NOVA_PROBE_RENDER_SCALE cuts the shading to a sixteenth, so
it is the window and not the drawing.
It is an ADDITIVE constant, not a scale factor. An A/B whose two arms share a window size divides it out, so ratios and ablations under Xvfb stand. A budget, an FPS gate and a “this scene costs N ms” claim do not.
Two knobs make it visible. NOVA_PROBE_PRESENT=immediate names the presentation
mode instead of requesting AutoNoVsync - bevy logs the fallback for a named
mode and says nothing for the auto ones, so this is how a run proves it was not
capped at refresh. NOVA_PROBE_RENDER_DIAG=1 asks the renderer for GPU timestamp
queries and turns on the frame-cost report’s per-pass GPU table (it costs a
resolve pass and a readback, about 3% of the frame, so it is never a default).
So an absolute number wants a REAL display, and the window does not have to be
in your way to get one. An armed run wears the WM_CLASS / app id
nova_core::MEASURE_WINDOW_CLASS and no other run does, so a window manager
can send it elsewhere on its own - on i3,
for_window [class="nova-measure"] move container to workspace 3. The class is
deliberately distinct from the normal one, so a placement rule can never catch
a hand-run somebody is playing.
Where a frame’s milliseconds went
Any armed capture also logs a nova framecost: line and, under it, three
tables: every main-world schedule, every top-level RenderSystems phase with
the render graph carved out of Render so the submit and the present are
visible on their own, and every render pass the device timed. Read them
together - the main world and the render world overlap under pipelined
rendering, so a frame costs about the longer of the two, and GPU far under both
says the device is not the constraint.
Beside it, nova census: counts the world once per capture: entities by
component, the archetypes they fall into, and mesh instances against DISTINCT
mesh handles. Instances and distinct always side by side - 12,572 instances over
681 meshes is a different story from the 12,572 alone. Distinct handles are what
a draw call bins on, so the pair is what separates “the scene is big” from “the
scene batches badly” (see
Why cracks are QUANTISED).
The fixed loop is single-threaded on purpose
AppBuilder::assemble puts FixedFirst through FixedLast on Bevy’s
single-threaded executor, so a schedule table’s fixed-loop rows are self time,
not fan-out. Those schedules run 64 times a second and are made of many small
systems; the multithreaded executor’s per-schedule task fan-out costs more than
the parallelism buys. Matched at 650-750 dynamic bodies in a 1v1
wfc_arena fight with infinite ammo, 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. Current wfc_arena fighters carry finite ammo, so their runs are not
directly comparable with these arena numbers.
Avian’s PhysicsSchedule and SubstepSchedule are LEFT multithreaded. The same
switch applied to them moved no step metric and made the frame tail worse (p99
36.9 ms against 40.6): the solver’s par_for_each passes are the one part of a
fixed step that does saturate threads. Re-measure before moving either boundary.
The compute pool is capped at eight workers
AppBuilder::assemble sets Bevy’s TaskPoolPlugin with
compute.max_threads = nova_core::MAX_COMPUTE_WORKERS, which is 8.
Bevy’s default hands the compute pool every core left after the IO and
async-compute pools take four each, so it is total - 8 on any host with
enough cores to fill them - 16 workers on a 24-thread desktop. The cap binds
only above 16 logical CPUs: a 16-thread host already lands on eight, and a
smaller one lands under it. On wasm the pool is single-threaded and the number
is ignored.
The extra workers were not doing extra work. bevy_transform’s propagation
workers share one queue behind a spin retry
(bevy_transform-0.19.1/src/systems.rs:574-610), and in a measured 4v4
wfc_arena fight with infinite ammo the sampled spin was 23-24% of all CPU at
16 workers against 8-9% at eight. Five release repeats per arm, same binary,
same seed: the mean frame time went 18.69 ms -> 13.41 and p99 41.18 ms ->
27.39, with the per-repeat ranges disjoint on both. The current finite-ammo
arena is not directly comparable with these numbers. stress_bullets and
stress_point_defense moved the same way. Four workers removed still more
spin and used less total CPU but was about 4.9% SLOWER than eight on the mean,
and the two p99 ranges overlap - minimising CPU work is not minimising frame
latency.
Eight is a measurement on one hybrid i9, not a proven optimum for every CPU. Re-measure before moving it.
Measure at the shipped cap, not above it
A worker count is a contention regime, and the regime is an amplifier. The same pair of revisions measured across the same code change read +33.5% at 16 workers and +10.2% at eight: the busier pool turns a small workload increase into a large frame-time increase. A bisection run at the old 16-worker default therefore over-attributes cost to whichever commit added work, and its boundaries are not comparable with a capped run’s. Say which regime a number came from, and prefer the shipped cap.
The window, and the deadline sized to it
The capture window is the capture crate’s full 180/900 baseline unless the
example declared one of its own, so probe numbers stay comparable with the
sweep’s; your NOVA_PROBE_WARMUP / NOVA_PROBE_FRAMES override both. The
completion deadline is SIZED to the BASELINE window (not a flat 120s, and a
ceiling for any shorter one an example declares): probe sets
NOVA_AUTOPILOT_DEADLINE for the fps pass to (warmup + frames) / ~2fps + margin, so a slow-but-progressing capture (a heavy scene in a dev build under
software rendering - the stress_* ranges are the case) completes instead of
tripping the hang detector; a genuine hang still fails at a window-appropriate
bound, and your own NOVA_AUTOPILOT_DEADLINE overrides it. The behavioral
(clean) pass has no window to size against, so it sizes from --timeout
instead - 30 s under it, so the named-laggards line still beats the
supervisor’s kill - which is what makes --timeout move the budget of a long
correctness range. Every example’s main
returns AppExit, so a deadline expiry is a non-zero process exit the
process_exit check reports. See the crate docs for the full knob list
(NOVA_PROBE_*).
Sweeping presets, renderers and the web
The perf sweep is the same front door: a preset matrix of the frame-time
capture, one labeled frametime.csv row per cell, release-built (dev-profile
frame numbers are not baselines):
cargo run --features debug probe run stress_bullets --release --preset high --preset low
cargo run --features debug probe run stress_bullets --release --render sw ... # lavapipe floor
cargo run --features debug probe run stress_bullets --release --norender # no renderer at all
cargo run --features debug probe run <scenario> --platform web # web/WebGPU capture (scraped)
--render picks the BACKEND for every pass, the frame-time one included: sw
forces the lavapipe ICD and its short 20/120 window. --norender decides
whether anything draws at all, sets NOVA_NORENDER in the child, starts no
Xvfb, and is refused alongside --render - there is no backend to pick when
nothing draws. It keeps the 180/900 baseline window, because a headless run has
no fill cost to shorten around, and it is native only: a wasm run has no process
environment to set. Its rows name themselves in frametime.csv - backend and
adapter both read unknown, there being no adapter. It measures the main
schedule alone, so it CANNOT see a render-side panic; a speed option beside a
rendered run, never instead of one.
To measure a named SHIPPED scenario, use the probe scenario verb rather than
run --scenario: it launches the game binary itself and needs no example.
run --scenario only sets NOVA_PROBE_SCENARIO, which no cataloged example
reads on the native side; on --platform web it is load-bearing, because
nova_perf_web takes the scenario id from the URL. The shipped binary opens
its window only once the scenario is BUILT - its camera is up and the loader’s
gate is released - because the loader holds Time<Virtual> through the build
and a window opened on that hold is refused as simulation_stopped. A build
that fails never releases the gate, so the capture stays armed and silent and
the probe’s own timeout ends the run; nothing admits a failed start.
Every capture records run metadata (wgpu backend + GPU adapter, resolution,
graphics preset, git SHA, host and the BUILD PROFILE) so a results file names
its own renderer. That metadata is required, not optional: the reader accepts
one header only, and a results file from an older schema is refused with
unexpected CSV header rather than loaded with values it never carried. The
report badges each row dev or release: dev numbers are NOT baselines, and
because the capture is wired by default, the badge is what keeps ad-hoc dev
captures from being mistaken for comparable measurements. The web platform
builds the perf_web wasm app through Trunk, serves it from an embedded static
server, drives headless Chromium with the calibrated WebGPU flags, and
scrapes the summary line into a labeled CSV row (no fs in the browser).
Compare runs with probe report <after> --baseline <before> - signed deltas
per label - and report only accepts dirs probe itself produced
(probe-run.json is the gate).
Profiled pass (where does the time go)
Per-system costs come from a SEPARATE traced run - tracing overhead inflates frame times, so a profiled run RANKS systems while the clean capture owns the FPS truth (never mix the two):
cargo run --features debug probe run system_scenario_grammar # trace + report table
cargo run --features debug probe run system_scenario_grammar --samply # + flamegraph
The profiled pass builds with --features debug,trace (bevy’s per-system
spans are compiled in only under bevy/trace), runs headless with
TRACE_CHROME into the run dir (plus the RUST_LOG=bevy_ecs=info override
that un-hides the spans from the game’s log filter), and the report renders
the top-N table (probe report <run-dir> re-renders it). Open the raw
trace.json in https://ui.perfetto.dev for the full picture; samply load
opens the flamegraph in the Firefox Profiler (the samply run is skipped with
a note when samply is missing or blocked - sampling needs
perf_event_paranoid <= 1 AND, on many-core hosts, enough perf ring-buffer
memory: an “mmap failed” means raising perf_event_mlock_kb, e.g.
echo 16384 | sudo tee /proc/sys/kernel/perf_event_mlock_kb). The samply
run builds with the dedicated profiling cargo profile (full DWARF in the
binary + frame pointers via RUSTFLAGS) so our frames symbolicate to real
names instead of raw addresses; frames inside the NVIDIA driver blob and
stripped system libraries stay hex - that is their stripping, not a build
problem. Load the profile right after recording: symbolication resolves
from the binary on disk, so a rebuild in between loses names.
Expect the trace to be enormous. It carries one span per system per frame and grows at roughly 28 MB per second of traced gameplay, with nothing capping it, so a long range leaves GIGABYTES in its run dir - the report prints the size beside the table. That is deliberate: the raw file is the Perfetto artifact, and a byte cap or a span filter would buy disk by truncating the deep dive it exists for. The host reads it as a STREAM instead, at flat memory (about 70 MB peak, whatever the file’s size), so the cost is disk and disk only. It is a scratch artifact: keep it while you are profiling, delete the run dir when you are not.
Streamed-world fill and steady windows
world_clusters probes a 125-cell, content-rich streaming window. Its default
capture waits for all 125 cells to be live and planned, warms up for 60 frames,
then records 300 steady frames without camera travel. Set
NOVA_WORLD_FILL_PROBE=1 for a separate 40-frame capture starting at 60 live
cells. These windows do not have the same workload: check live root counts,
fixed-step counts and admitted repeats before comparing them. The fill window
can advance different numbers of cells in the same 40 frames. Per-cell
nova_world::prepare_sector trace spans separate worker generation, validation,
rock geometry and planet preparation; deferred command application and Avian
physics appear in the existing system spans. Profiled spans rank causes but
cannot be substituted for the clean capture’s frame times.
Find it in the code
- The capture, its window and its knobs:
FrameTimePlugin,nova_frametime-crates/nova_probe/src/capabilities/frametime.rs. The crate rustdoc (cargo doc --open -p nova_probe) carries the fullNOVA_PROBE_*table, native env var against wasm query string. - Where the milliseconds went:
FrameCostPlugin-crates/nova_probe/src/capabilities/framecost.rs; the GPU half is gated onnova_core::RENDER_DIAG_ENV. - What an armed run looks like from outside:
nova_core::PROBE_ENVandnova_core::MEASURE_WINDOW_CLASS-crates/nova_core/src/lib.rs.nova_probere-exportsPROBE_ENV; it lives down there because the window builder needs it. - What the scene contained:
CensusPlugin-crates/nova_probe/src/capabilities/census.rs. - Driving a repeat set, the presets, the web platform, the traced and samply
passes:
crates/nova_probe_cli/src/native/. Reading one back - the validity band and the refresh-cap discriminator -read_repeatsandRefreshCapincrates/nova_probe_cli/src/evaluation/frames.rs; the stats and CSV/JSON schema both halves speak -crates/nova_probe/src/stats.rs. - The bundle an example wires:
NovaProbePlugin-crates/nova_probe/src/capabilities/mod.rs.
Add a ship section
Ship-section kinds are a CLOSED enum. There is no data-driven registry for
them: SectionKind (crates/nova_ship/src/sections/base_section.rs) is a
Rust enum, every match on it is exhaustive, and the compiler will not let you
land a new variant until every site handles it. Adding a kind is a fixed
sequence of ~10 edits across nova_ship, nova_gameplay, nova_scenario,
nova_editor, nova_interface, and nova_authoring,
ending at a runnable example.
Why it is closed
Section kinds are first-class engine concepts, not content. Each kind carries
its own typed config, its own spawn bundle, its own behavior systems, and its
own arm in every exhaustive match over section kinds – none of which a RON
author could supply. What IS data-extensible is the section CATALOG: a SectionConfig
(base stats + one SectionKind instance) is authored in RON and loaded at
runtime (see the Ship sections reference and
modding). New KINDS are code; new INSTANCES of an existing
kind are data. This guide is about the former. The editor’s section bench
builds instances: it copies a prototype or a plain hull and edits it, and it
cannot add a kind.
The closed enum is a feature: it means “did I wire the new kind into the ship computer / the editor / spawning?” is a compile error, not a silent gap.
flowchart LR
A[config module] --> B[SectionKind enum]
B --> C[SectionClass label]
C --> D[section plugin]
D --> E[spawn arm]
E --> F[editor place + gallery]
F --> G[asset prototype]
G --> H[example]
Checklist
Do these in order. Steps 2-7 will not compile until the ones before them exist, and the exhaustive matches force you through 3-7 once the enum has the variant.
Replace <kind> / <Kind> below with your section name (e.g. shield /
Shield).
-
New config module. Create
crates/nova_ship/src/sections/<kind>_section.rs, modelled onhull_section.rs(simplest) orturret_section/(a multi-file module: behavior + FixedUpdate systems). It defines: a<Kind>SectionConfigstruct, a<kind>_sectionbundle fn, a<Kind>SectionMarkercomponent, a<Kind>SectionPlugin, and apreludere-exporting them. The bundle MUST insert the marker and theSectionClassfor the kind:#![allow(unused)] fn main() { pub fn shield_section(config: ShieldSectionConfig) -> impl Bundle { ( ShieldSectionMarker, SectionClass::Shield, // ... kind-specific render/behavior components ) } }Any distance, speed or acceleration on
<Kind>SectionConfigis typedMeters,MetersPerSecondorMetersPerSecondSquared(nova_events::units) and never a baref32- a reach, a blast radius, a muzzle speed. The type is what documents the unit for the RON author and what the editor stamps the row’sm/m/s/m/s2from, andto_engine()where the value reaches Bevy or avian is the one crossing. What stays a baref32is what the engine owns: a collider extent or a build-grid offset, which are cells. See Units and scale.Then register the module in
crates/nova_ship/src/sections/mod.rs: addpub mod <kind>_section;and re-export<kind>_section::prelude::*in the moduleprelude. -
Add the enum variant. In
crates/nova_ship/src/sections/base_section.rs, add the variant toSectionKind(grep forenum SectionKind):#![allow(unused)] fn main() { pub enum SectionKind { Hull(HullSectionConfig), Thruster(ThrusterSectionConfig), Controller(ControllerSectionConfig), Turret(TurretSectionConfig), Torpedo(TorpedoSectionConfig), Shield(ShieldSectionConfig), } } -
Section class. Add the variant to
SectionClassincrates/nova_gameplay/src/damage.rs(grep forenum SectionClass). It is a LABEL, not a damage key – there is no resistance table, so there is nothing else to fill in on the damage NUMBER. How much a section takes is itshealth; how far a round gets through it is the travel rule, which readsHealth.maxand never the class. What the section LOOKS like as it is damaged is a separate, authored decision - step 8.The interface’s Ship pane labels sections by this enum: add an arm to the exhaustive matches
code_prefixandkind_indexincrates/nova_interface/src/ship/sections.rs,class_nounincrates/nova_interface/src/ship/refit.rs, andSectionIconType::ofincrates/nova_interface/src/icons.rs. -
Wire the section plugin. In
crates/nova_ship/src/sections/mod.rs, add your plugin to theadd_plugins((...))tuple inSpaceshipSectionPlugin::build(grep forimpl Plugin for SpaceshipSectionPlugin), passing therenderflag like the others:#![allow(unused)] fn main() { <kind>_section::ShieldSectionPlugin { render: self.render, }, } -
Spawn arm. In
crates/nova_scenario/src/objects/spaceship.rs, add a match arm toinsert_spaceship_sections(grep for it, then itsmatch &config.kind). At minimum insert the kind bundle; add input-binding handling only if your kind needs it (see theTurret/Thrusterarms for that pattern):#![allow(unused)] fn main() { SectionKind::Shield(shield_config) => { section_entity.insert(shield_section(shield_config.clone())); } }This is the production spawn path; see the Scenario engine for how the spaceship object and its section observer fit together.
-
Placement and preview arms. Three exhaustive matches, in three files.
default_binds(crates/nova_editor/src/placement.rs) gives the kind its default key/pad binding in the editor, orvec![]if it takes none - modelHullfor unbindable,ThrusterorTurretfor bindable.added_section_binds(crates/nova_ship/src/sections/section_add.rs) gives the binding a section of the class takes when a station adds it to a ship.insert_preview_section(crates/nova_ship/src/sections/section_preview.rs) inserts the kind’spreview_<kind>_section(...)bundle beside the sharedpreview_section(...)recipe. Write that preview half in your section module beside<kind>_section: the render mesh and the config the render observers read, and none of the live state. A preview carries no input binding.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 is generic too, so the arms only insert. -
Parts gallery category + readouts. In
crates/nova_ship/src/gallery/catalog.rs, add an arm toGalleryCategory::of()and tobehaviour(). Both matchSectionKindexhaustively, so the compiler walks you through it. Put the kind under an existing category, or add aGalleryCategoryvariant with itsROWentry andlabel()arm.kind_label(), the tile’s category line, reads the category’s label, so it needs no arm:#![allow(unused)] fn main() { // GalleryCategory::of SectionKind::Shield(_) => Self::Shields, // behaviour - the two or three numbers a builder picks the part BY SectionKind::Shield(shield) => { vec![("Capacity".to_string(), format!("{:.0}", shield.capacity))] } } -
Asset prototype. Add a family module under
crates/nova_authoring/src/base_content/sections/(or extend the one this kind belongs to) and return yourSectionConfigfrom itsprototypes(), so the catalog ships a ready-to-place instance. Give it a stable snake_caseid(this is whatsections.get_section("...")and RON authors reference). The id is a runtime STRING that nothing type-checks: keep it a literal beside the builder, and promote it to aconstincrates/nova_ship/src/sections/catalog_ids.rsonly when a crate that cannot reachnova_authoringhas to name it.#![allow(unused)] fn main() { SectionConfig { base: BaseSectionConfig { id: "basic_shield_section".to_string(), name: "Basic Shield Section".to_string(), description: "A basic shield section for spaceships.".to_string(), health: 100.0, build: recipes::shield(), damage_effects: DamageEffects(vec![DamageEffect::Cracks, DamageEffect::Sparks]), ..default() }, kind: SectionKind::Shield(ShieldSectionConfig { /* ... */ }), }, }buildis required: add a recipe row tocrates/nova_authoring/src/base_content/sections/recipes.rsfrom thePartsitems initems.rs. Content lint refuses a recipe that strips for as much as it costs (see Ship sections internals).damage_effectsis a real design decision per kind, not boilerplate. The shipped rule: a hull authors nothing (defaulting to[Cracks]), anything carrying machinery addsSparks, and a thruster addsPlumeon top. Pick by what the part IS - a turret is all function and fails by sparking, a hull has nothing but material to lose. The rule the vocabulary is kept honest by: no section loses geometry, so every effect is a material or a particle. See Ship sections internals.If your config needs a render-mesh
AssetRef, add a field toBaseContentAssetsand itsfrom_paths()incrates/nova_authoring/src/base_content/assets.rs.The builders do not feed the game directly: regenerate the committed RON with
cargo run content genand commitassets/base/sections/base.content.ronwith the code change - thecontent_ron_paritytest fails on drift. -
Example. Add
examples/systems/<what it proves>.rs, modelled on the existing per-section ranges (system_attitude_hold.rsandsystem_thrust_and_plume.rsare the most compact), plus its[[example]]block in the root Cargo.toml (auto-discovery is off; the catalog is the source of truth) -catalog_matches_diskincrates/nova_probe_cli/tests/catalog_drift.rsfails until disk and catalog agree, andsystems_ranges_assert_their_invariant_rosterbeside it fails until the new range has a named invariant roster. The example builds a minimalScenarioConfig(a controller + your section), triggersLoadScenario(...), and under--features debugdrives an autopilot probe that asserts the kind’s behavior end to end. Run it:NOVA_AUTOPILOT=1 cargo run --example <what it proves> --features debug
Done
The compiler is your checklist enforcer for 2-7: if it builds, every exhaustive match handles the new kind. Steps 1, 8, and 9 are the ones with no compile-time backstop – the module wiring, the catalog instance, and the runnable proof – so double-check those by hand.
Find it in the code
- The enum and base config:
SectionKind,BaseSectionConfig-crates/nova_ship/src/sections/base_section.rs; model moduleshull_section.rs(minimal) andturret_section/beside it. - Class label:
SectionClass-crates/nova_gameplay/src/damage.rs; the interface’s label matches -crates/nova_interface/src/ship/sections.rs. - Spawn arm:
insert_spaceship_sections-crates/nova_scenario/src/objects/spaceship.rs. - Placement and preview arms:
default_binds-crates/nova_editor/src/placement.rs;added_section_binds-crates/nova_ship/src/sections/section_add.rs;insert_preview_section-crates/nova_ship/src/sections/section_preview.rs. - Gallery arms:
GalleryCategory::of,behaviour-crates/nova_ship/src/gallery/catalog.rs. - Cross-crate prototype ids:
crates/nova_ship/src/sections/catalog_ids.rs. - Prototypes:
section_catalog-crates/nova_authoring/src/base_content/sections/mod.rs, with oneprototypes()per family module beside it. - API detail:
cargo doc --open -p nova_ship.
Extend the scenario engine
The scenario engine is config-open but code-closed. Authoring a scenario in RON
from the primitives that already exist is a data change and lives in its own
guide (see Create your first scenario). Adding a NEW
primitive - a new event, filter, action, or object kind - is a Rust change, and
each of the four follows one repeated recipe: define the config, wire it into
the matching dispatch enum, add the arm on the trait impl that fans out to it,
and export it. This is the “how to add” companion to
Scenario engine (the “what it is” reference); read that
first for the vocabulary (NovaEventWorld, handlers, filters, actions, scoped
objects).
The dispatch shape is identical everywhere: an enum variant carries a config
struct, a match arm on the trait impl delegates to that config’s own impl.
flowchart LR Variant["Config enum variant"] --> Arm["match arm on the trait impl"] Arm --> Impl["config's own trait impl"] Impl --> Effect["fires / filters / mutates"]
The NovaEventWorld seam
Read this once; recipes 2 and 3 depend on it. Filters and actions never touch
the Bevy World directly. They see only NovaEventWorld
(crates/nova_scenario/src/world.rs), the resource that holds scenario state:
variables, objectives, next_scenario, and a queue of deferred command
closures. What a filter or action may touch:
world.get_variable(key)/world.insert_variable(key, VariableLiteral)- the typed scenario variables.world.push_objective(ObjectiveActionConfig)/world.remove_objective(id)- HUD objectives (synced write-on-diff intoGameObjectives).world.next_scenario = Some(NextScenarioActionConfig { .. })- queue a scenario switch.world.push_command(|commands| ...)- defer anything that needs real world access (spawning, querying entities, resource mutation). The closure gets a&mut Commands; for a full&mut World(id -> Entity lookups) queue acommands.queue(move |world: &mut World| ...)inside it, the shapeDespawnScenarioObjectActionConfiguses.
Nothing runs against the world synchronously. Each frame
NovaEventWorld::state_to_world_system (in world.rs) syncs objectives into
GameObjectives, runs a queued non-lingering NextScenario switch, then drains
the command queue - so every push_command closure lands at frame end, in
order. The unit tests in the actions/ submodules exercise exactly this:
mutate a NovaEventWorld, call
NovaEventWorld::state_to_world_system(&mut world) to
drain, then assert on the world (see
despawn_action_removes_the_scoped_object_by_id in actions/spawn.rs).
Recipe 1: add an event kind
An event is fired somewhere in the engine, and scenarios react to it through a
handler. Adding one is two files: the event type (nova_events) and its config
variant (nova_scenario), plus the firing site.
The example below is a kind that does NOT ship, so it stays a recipe. The
closest shipped pair to read beside it is the docking lifecycle: OnDocked /
OnUndocked in nova_events/src/lib.rs, their table rows in events.rs, and
track_docking_transitions in loader/trackers.rs.
-
In
crates/nova_events/src/lib.rsdefine the marker event and its info struct with theEventKindderive. The info is what a handler’s filters read; give it the pair shape (id,other_id,other_type_name) if it targets an object so theEntityfilter composes like the others.#![allow(unused)] fn main() { #[derive(Debug, Clone, EventKind, Reflect)] #[event_name("onscancomplete")] #[event_info(OnScanCompleteEventInfo)] pub struct OnScanCompleteEvent; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, Reflect)] pub struct OnScanCompleteEventInfo { #[serde(rename = "id")] pub id: String, #[serde(rename = "other_id")] pub other_id: String, #[serde(rename = "other_type_name")] pub other_type_name: String, } }Export both from the
nova_eventsprelude (thepub use super::{...}block at the top oflib.rs). -
In
crates/nova_scenario/src/events.rsadd a ROW to thescenario_events!table near the bottom of the file. The enum, theFrom<EventConfig> for EventHandler<NovaEventWorld>arm,ALL,COUNT,nameandlabelare all generated from it - do not hand-edit any of them, and do not edit the macro body above the table:#![allow(unused)] fn main() { scenario_events! { // ... /// Fires once a ship finishes scanning an object (`id` = the object, /// other = the scanning ship). OnScanComplete => OnScanCompleteEvent { label: "On Scan Complete" }, } }The doc comment on the row is the one a reader gets; the
labelis the row the editor’s trigger menu lists it under. -
Fire it. Engine-driven events fire from
crates/nova_scenario/src/loader/withcommands.fire::<OnScanCompleteEvent>(OnScanCompleteEventInfo { .. })(see theOnStartsite inloader/lifecycle.rs,OnUpdateinloader/clock.rs, and orbit-lifecycle/lock/docking events inloader/trackers.rs); object-local events (an area entering/leaving) fire from the object’s own observer, the wayobjects/area.rsfiresOnEnterEventfrom its own trigger. A kind may also fire from a system it owns:objects/asteroid_carve.rsfiresOnDestroyedEventout ofcarve_asteroid_fieldswhen a rock’s field is exhausted, because “destroyed” there is a geometry test rather than a health-zero marker.
EventConfig is Copy and derives serde, so the new variant is authorable from
RON with no extra work.
Recipe 2: add an event filter
A filter gates whether a handler’s actions run; all filters on a handler must
pass. Everything lives in crates/nova_scenario/src/filters.rs.
-
Define the config struct and its
EventFilter<NovaEventWorld>impl.filterreturns a bool and may readworld(variables) andinfo(the fired event data); it must not mutate.#![allow(unused)] fn main() { #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct VariablePresentFilterConfig { pub key: String, } impl EventFilter<NovaEventWorld> for VariablePresentFilterConfig { fn filter(&self, world: &NovaEventWorld, _: &GameEventInfo) -> bool { world.get_variable(&self.key).is_some() } } } -
Add the variant to
EventFilterConfig(grep forenum EventFilterConfig) and the arm toimpl EventFilter<NovaEventWorld> for EventFilterConfig(grep forimpl EventFilter<NovaEventWorld> for EventFilterConfig):#![allow(unused)] fn main() { pub enum EventFilterConfig { Entity(EntityFilterConfig), Conditional(ConditionalFilterConfig), Expression(ExpressionFilterConfig), VariablePresent(VariablePresentFilterConfig), } // in the filter match: EventFilterConfig::VariablePresent(config) => config.filter(world, info), } -
Export the config struct from the module
prelude(thepub use super::{...}block at the top offilters.rs). -
Make it authorable in the editor (see Two surfaces author the vocabulary): derive
Reflecton the config, tag any string that names something with theNamesattribute, and add theFilterChoicevariant incrates/nova_editor/src/event.rs-ALL,label,stem,operands,stock, plus thefilter_choice,filter_configandfilter_config_mutarms. The matches are exhaustive, so the compiler names every one of them.
Recipe 3: add an event action
An action runs when a handler passes, in order. Everything lives in
crates/nova_scenario/src/actions/: the TABLE in actions/mod.rs, the macro
that reads it in actions/registry.rs, and each action’s config and impl in
the submodule for what it touches (audio.rs, cinematic.rs, flow.rs,
mission.rs, sequence.rs, ship.rs, spawn.rs, timer.rs, view.rs).
EventActionConfig is GENERATED. scenario_actions! turns one table row into
the enum arm, the dispatch, the creative-map class, the reflected payload
accessor and the whole authoring-menu surface. Four things it cannot generate
are yours: the payload struct, the editor’s stock value, the lint arm if the
action names anything, and the docs.
-
Define the config struct and its
EventAction<NovaEventWorld>impl.fn action(&self, world: &mut NovaEventWorld, info: &GameEventInfo)mutates the seam and nothing else - useworld.insert_variable,world.push_objective,world.next_scenario, orworld.push_command(...)for world access. Anything needing an id -> Entity lookup queues acommands.queue(move |world: &mut World| ...)inside the pushed command, scoped withWith<ScenarioScopedMarker>(a raw id match would also hit ship sections that carryEntityId).#![allow(unused)] fn main() { #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct VariableClearActionConfig { pub key: String, } impl EventAction<NovaEventWorld> for VariableClearActionConfig { fn action(&self, world: &mut NovaEventWorld, _: &GameEventInfo) { world.insert_variable(self.key.clone(), VariableLiteral::Boolean(false)); } } } -
Add a ROW to the
scenario_actions!table inactions/mod.rs. Rows are in the order an authoring menu lists them - mission surface, then the world, then the ships in it, then the run’s own flow, authoring aids last - and that order ISActionTag::ALL, so put the row where the action belongs in the menu:#![allow(unused)] fn main() { registry::scenario_actions! { // ... /// Clear a scenario variable back to false. VariableClear(VariableClearActionConfig) { label: "Variable Clear", stem: "clear", effect: Bookkeeping, inspect: Reflect, }, } }The two fields worth thinking about:
effectis the creative-map class.Bookkeepingtouches only scenario state and presentation;Injectionreaches into the simulation in a way playing could not have produced, and the badge reports it by name;Nestedmeans the class is whatever the actions it schedules are worth.inspectisReflectfor every action but one: the editor draws the payload’s fields from the reflection.Opaqueis for a payload the editor lifts into its own tree and draws itself, which today is only a beat chain.
If the action NESTS other actions, it also needs an arm in
EventActionConfig::step_chainandstep_chain_mut(actions/mod.rs). That is the one place a nesting arm is declared, and every walker in the tree - the lint, the frame grouping,walk,walk_mut- reads it. Adding the arm is the whole job; missing it is a bug that only shows up in the walker nobody thought about. -
Export the config struct from the
actions/mod.rspreludeblock. -
Make it authorable in the editor: derive
Reflectand tag the naming strings, as in recipe 2. The editor’s list is not its own -ActionChoiceis a type alias for the scenario crate’sActionTag(crates/nova_editor/src/event.rs), soALL,labelandstemcome off the table row you just wrote and there is no second list to keep in step. What the editor still owes you is one arm inActionChoiceExt::stock: the value a freshly added action starts life as, with every id field EMPTY. The match is exhaustive, so the compiler asks for it.A leaf action needs nothing else from the tree. An action the editor holds as more than a leaf - because its steps are child NODES, or because its pop-up shows a head rather than the whole payload - is an
ActionKindvariant of its own, and owes arms inaction_config,action_config_mutandaction_choicebeside it.SequenceandCinematicare there because their steps are children;VariableSetbecause its pop-up is a head. All three matches are exhaustive. -
Lint it, if it names anything. An action that carries an id or a file reference needs an arm in
crates/nova_scenario/src/lint/so an unresolvable name is an error at lint rather than a surprise at load. This is the fourth thing the table cannot generate.
Templates: the tests at the bottom of the actions/ submodules are the
pattern to copy -
despawn_action_removes_the_scoped_object_by_id (actions/spawn.rs; queued
world lookup, scoped),
hint_emphasis_actions_drive_the_resource (actions/mission.rs; resource
mutation through the drain),
objective_marker_attach_and_detach_drive_the_component (actions/mission.rs;
component insert/remove).
Each fires the action into a NovaEventWorld, drains with
NovaEventWorld::state_to_world_system, then asserts.
Recipe 4: add a scenario object kind
A scenario object is a scoped entity spawned by SpawnScenarioObject; the
kind decides its own body (or none - three of the six shipped kinds are
static). Model it on crates/nova_scenario/src/objects/beacon.rs. Do NOT model
it on the asteroid: it is the least representative kind in the directory, split
across three modules with two plugins, carrying no Health and outside the
integrity graph entirely.
A kind is not required to be a physical body.
beacon.rsdeclaresRigidBody::Static(the base bundle supplies no body), andlight.rsis a pure-render kind: it splits config from component with anAddobserver so therenderflag can skip the Bevy light entirely for headless tools. Note that scene lighting itself is authored content - a scenario with noLightobject renders black, so any new example or fixture that renders needs one.
-
Add the type-name const to
crates/nova_events/src/lib.rs, besideEntityTypeNameand the other*_TYPE_NAMEvalues, and export it from that crate’s prelude. It goes there, not beside the object, because a reader that matches on it -nova_interface’s map,nova_gameplay- must not depend onnova_scenarioto name a kind (CONVENTIONS, Nova 5).#![allow(unused)] fn main() { /// [`EntityTypeName`] value for an authored mine. pub const MINE_TYPE_NAME: &str = "mine"; } -
Create
crates/nova_scenario/src/objects/<kind>.rs. It holds a config struct, a marker component, a<kind>_scenario_object(config) -> impl Bundlebuilder, and (optionally) aPluginfor any observers/systems the kind needs. The bundle carries the marker plus anEntityTypeName; the sharedbase_scenario_object(id, name, transform, visibility,ScenarioScopedMarker) is added by the spawn path, not here. It deliberately carries NO body - each kind declares its ownRigidBody(the asteroid addsDynamic+TransformInterpolation; three of the six kinds are static).Every distance, speed or acceleration the config carries is
Meters,MetersPerSecond,MetersPerSecondSquared, orMetersPerSecond3(nova_events::units), never a baref32: the TYPE is what says what the number means, it is what the editor stamps a row’s unit from, andto_engine()at the spawn site is the one place it becomes a Bevy or avian number. Vector positions areMeters3; vector velocities areMetersPerSecond3. Mobile asteroids and all ship roots are gravity-affected; planets are static gravity wells with required mass. The exception, and the only one, is a value the engine owns outright - a build-grid cell, a collider extent. See Units and scale.An authored field NAMES its value. A config that carries a
Stringid has one table that says which ids exist, and an id that table does not know is an error at the earliest point that can see it - acontent linterror first, a loud refusal on the load path second. It is never resolved to a house answer. AnOptionis right only where absence is a documented OVERRIDE with runtime-derived behavior behind it, and the field’s own doc comment says what the absence MEANS. A rock’slock_signature(absent = 100 m plus half its true geometric radius) is one shipped precedent.PlanetConfig::massis required because every planet is a static gravity well. There is noAsteroidConfig::massfield: mobile asteroids and ship roots respond to gravity rather than authoring wells.AsteroidConfig::kindis a plain requiredAsteroidKindId, because shading has no runtime answer to fall back on.#![allow(unused)] fn main() { #[derive(Component, Clone, Debug, Reflect)] pub struct MineMarker; #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct MineConfig { /// Trigger radius. pub radius: Meters, pub damage: f32, } pub fn mine_scenario_object(config: MineConfig) -> impl Bundle { ( MineMarker, EntityTypeName::new(MINE_TYPE_NAME), // ... the kind's own components ) } pub mod prelude { pub use super::{mine_scenario_object, MineConfig, MineMarker}; } } -
Register the module in
crates/nova_scenario/src/objects/mod.rs: addpub mod <kind>;, re-export<kind>::prelude::*from themod.rsprelude, and if the kind has a plugin add it inScenarioObjectsPlugin::build(likeAsteroidPlugin, which takesrender). -
In
crates/nova_scenario/src/actions/spawn.rsadd the variant toScenarioObjectKind(grep forenum ScenarioObjectKind) and the spawn arm inimpl EventAction<NovaEventWorld> for ScenarioObjectConfig:#![allow(unused)] fn main() { pub enum ScenarioObjectKind { Asteroid(AsteroidConfig), Spaceship(SpaceshipConfig), Beacon(BeaconConfig), Light(LightConfig), Mine(MineConfig), } // in the spawn match: ScenarioObjectKind::Mine(config) => { entity_commands.insert(mine_scenario_object(config.clone())); } }
The new kind is now spawnable from any handler, in RON, as a
SpawnScenarioObject action:
SpawnScenarioObject(ScenarioObjectConfig(
base: BaseScenarioObjectConfig(
id: "mine_1",
name: "Proximity Mine",
position: (0.0, 0.0, -2000.0),
rotation: (0.0, 0.0, 0.0, 1.0),
),
kind: Mine(MineConfig(radius: 50.0, damage: 40.0)),
))
Two surfaces author the vocabulary
A RON file is one way to write a handler; the editor’s EVENTS mode is the other,
and it draws every row it shows by REFLECTION off the same config structs. So a
config that is not Reflect is a construct the editor cannot show, and a string
field with no Names
attribute is a blank box where the block could have offered the ids the document
actually spawns.
AN AssetRef<A> FIELD PICKS ITS OWN FILE. The row 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 row says when the pointer rests on that row. Write it for the person filling the box in, not for the person calling the constructor: a field nobody documented is a row the editor cannot explain.
What each of the four recipes owes the editor:
| Recipe | What the editor needs |
|---|---|
| Event kind | Nothing. EventConfig is a Reflect enum of unit variants generated from the scenario_events! table, and the handler’s trigger row is walked off it, so a new event appears in the list. |
| Filter | Reflect + Names on the config, and a FilterChoice variant with its stock value. |
| Action | Reflect + Names on the config, and a stock arm. The choice list itself is the scenario crate’s ActionTag, so there is no variant to add - just the value a new action starts as. |
| Object kind | Reflect + Names on the config, an ObjectChoice variant in crates/nova_editor/src/node.rs to place it with, and the arms the compiler then asks for (glyph, preview, stage, inspect). |
stock is what the kind switch puts on a node the moment it is switched TO -
a valid config with empty ids, never a Default that lowers into something the
lint refuses.
Checklist
Whichever recipe you follow, the change is done when: the config struct derives
Clone, Debug, Reflect and the serde pair; every field says what it is for
in a doc comment; every string that names something carries its Names
attribute; every file a builder may change is an AssetRef<A> the reflection
can see; the table has its row (and, for a nesting action, its step_chain
arm); the type is exported from its module prelude; the editor has its stock
value (events excepted); the lint refuses a name that cannot resolve; and (for
an event) something fires it. Then it is reachable from code-built scenarios, from a RON
data file, and from the editor.
Find it in the code
- Events: the
scenario_events!table -crates/nova_scenario/src/events.rs; event types and theEventKindderive -crates/nova_events/src/lib.rs. - Filters:
EventFilterConfig-crates/nova_scenario/src/filters.rs. - Actions: the
scenario_actions!table -crates/nova_scenario/src/actions/mod.rs; the macro that reads it -actions/registry.rs(submodules: audio, cinematic, flow, mission, sequence, ship, spawn, timer, view). - Objects:
ScenarioObjectKind-crates/nova_scenario/src/actions/spawn.rs; kind modules undercrates/nova_scenario/src/objects/. - The seam:
NovaEventWorld-crates/nova_scenario/src/world.rs. - What a string names:
Names-crates/nova_scenario/src/names.rs; the editor’s choices:FilterChoice, andActionChoice(an alias fornova_scenario’sActionTag) withActionChoiceExt::stock-crates/nova_editor/src/event.rs. - API detail:
cargo doc --open -p nova_scenario(event engine:-p nova_events).
Giving a generated ship a front
Design note. Written in 2026-08 against examples/playable/shared/wfc.rs,
where the collapse used to live as consts in one example file. It lives in
crates/nova_wfc now and every number the note treated as a constant is a field
of a WfcPlan (nova_wfc::prelude::WfcPlan), so the citations below have been
re-derived. The REASONING is unchanged and is why the
note is kept.
The complaint it answers: looking at any render of the wfc_ships row, drives
point in several directions on the same hull. The generator places parts by
mating rules alone. Nothing knows a thruster belongs at the back, a bridge on
top, or guns where they can bear.
Three of the six recommendations below have landed since, and a fourth thing arrived that was not on the list. Each item carries its own status line.
1. The actual cause, which is narrower than “no sense of layout”
Two facts about the generator, together, produce the whole symptom.
A drive’s direction is not a choice the generator makes. The thruster
section carries ONE socket, on its forward end, and its exit normal is +Z in
its own frame. So the face it is bolted to IS the direction it exhausts. A drive
bolted to the roof fires up; one bolted to the transom fires aft. Nothing
“chooses badly” - the collapse never chooses at all, it just finds a hull face.
The mating rule is BINARY and cannot hold a fact about a whole ship.
compatible(tiles, here, face, there) reads one cell and its neighbour. “Aft” is
not a property of any pair of cells; it is a property of the grid. A binary
constraint cannot express it, ever, no matter how the tileset is authored.
So the symptom is not a missing heuristic. It is a category error: the file was asking a local rule to carry a global fact. Everything below is a way of putting that fact somewhere a local rule can see it.
Worth saying plainly what is NOT broken. Guns already point somewhere sensible, and for a reason: exit clearance forbids a muzzle whose lane is fouled, so a mount can only survive standing proud of the hull with space in front of it. Clearance is doing the “where they can bear” job already. Bays likewise - a broadside tube is a real warship, and a bay firing into its own hull is already illegal. The drive was the one part whose direction was both free and wrong.
2. What the grid already gives us to hang this on
More than expected. The generator is not orientation-blind - it is orientation-blind about parts while being quite opinionated about shape.
Every row was a constant when this was written. All but two are WfcPlan fields
now, which is the change that made the note need re-deriving rather than
retiring: the facts are the same facts, each caller can retune them, and
WfcPlan::standard_hull is the plan the numbers below come from. The plan is
code and not content - which generator wants which section is generator policy,
so a section stays a description of geometry and capability.
| Fact | Where | What it already means |
|---|---|---|
grid.length = 11 on z | WfcGrid, authored | the ship’s long axis |
grid.height = 5 on y; the keel row is height / 2 | WfcGrid; TileSet::hull | up, and a middle |
x = 0 is the mirror plane | Grid::starboard_half starts at x = 0.5 | port/starboard |
vacuum.bow_taper = 24.0 | WfcVacuum, read by Plan::vacuum_weight | z = 0 is the BOW |
vacuum.stern = 9.0 | WfcVacuum, same reader | the last row is sparse |
seed_keel | collapses the spine by hand, from keel.hull | a connected structure to grow on |
keel.bridge at length / 3 | the bridge, forward of centre | a front, already |
The answer to “does keel_component / the mating structure give a natural axis
to hang this on” is yes, and it is not the mating structure - it is the KEEL.
seed_keel already hand-collapses the spine before the generator gets a say,
and already puts keel.bridge forward of centre. The precedent for
“decide the big thing up front, let the collapse fill in around it” is in the
file, working, with a doc comment explaining why. Everything in this note is that
same move applied one more time.
What the grid does NOT give: any asymmetry in y. off_keel is
x + |y - keel_row| (collapse.rs, Plan::vacuum_weight), symmetric about the
keel row, so a hull’s deck and its belly are weighted identically. A ship generated here has a front and a back but
genuinely has no top.
3. What comparable generators do
Read rather than re-derived. Sources at the end.
Nobody makes a local constraint carry a global fact. The consistent answer across practitioner writeups is: decide the coarse thing FIRST, by other means, then let the constraint solve fill in detail. Boris the Brave calls this driven WFC and states the cost plainly - the algorithm already starts with a set of possible tiles per cell, so filtering that set before the solve is free. His worked example is Townscaper, where the player’s painted solidity decides which tiles may go in each cell and WFC never sees a global decision at all.
Per-cell BANS, not per-cell weights. This is the distinction that matters and it is easy to get wrong. Weights bias; they do not forbid. A low-weight drive at the bow still lands at the bow eventually, and worse, tile weights in the classic formulation are global frequency hints - vary them per cell and the entropy heuristic has to move from a count to a proper Shannon entropy over the surviving weights or the cell ordering stops meaning anything. A unary domain filter has none of that cost. This is standard constraint-solving practice, not a trick: unary constraints are handled by adjusting variable domains before the solve starts.
Buckets by normal direction, after the fact. a1studmuffin/SpaceshipGenerator
(MIT), the best-known procedural spaceship, builds a hull by extruding front and
rear faces and then categorises every face by its normal and rolls detail per
category - engines on rear faces, antennae on front and top, weapons on the
sides. Two things to take: detail follows orientation, and there is a GUARANTEE
GUARD - the rear-face rule fires an engine if the roll passes or the engine list
is still empty, because a pure probability table produces engineless ships.
Regions, if bans start causing contradictions. Merrell’s model synthesis “modifying in blocks” - solve overlapping blocks with their borders constrained to what is already placed, and restart the offending block rather than the whole output - is the standard answer, and Caves of Qud ships a version of it, running WFC with different settings on subsets of the map. This is the escape hatch, not the starting point. It is worth knowing that our collapse has NO backtracking and cannot get one cheaply, so anything that raises the contradiction rate is expensive here in a way it is not elsewhere.
Grammars are not automatically the semantics fix. Shape and graph grammars express “door on the front facade” naturally, because a grammar’s scope is an oriented box. But Merrell’s own graph-grammar work targets LOCAL SIMILARITY, the same goal as WFC, and does not claim to encode front/back/top. Swapping solvers does not buy the fact; putting the fact in the right place does.
4. Ranked recommendations
Cost against effect, as ranked at the time. Where they stand now:
| # | Then | Now |
|---|---|---|
| 1. Per-part aim | done | shipped, as WfcPart::aim |
| 2. Seed the drive deck | done | shipped, and it seeds a multi-cell drive whole |
| 3. Deck and belly | measured, not landed | unchanged: measured, not landed, still a taste call |
| 4. Seed a superstructure | proposal | not landed |
| 5. Zone the grid | “only if 1-4 are not enough” | the MECHANISM shipped as WfcPart::zone; standard_hull names none |
| 6. Re-tune the weights | last | not done; the weights are plan fields, so each caller retunes its own |
Not on the list and shipped anyway: a seeded SPINAL GUN (WfcKeel::bow_gun),
which is item 2’s move applied to the bow. It is what replaced the wfc_arena
bench stamping a lance on after the collapse.
1. A per-part AIM, as a unary constraint. SHIPPED
Landed as proposed, then moved into the plan: a draw entry carries
aim: Option<WfcAim> - the only face this part may fire through - and
Plan::domains strikes any tile Plan::aim_allowed disagrees with. The
shipped plan aims its thrusters Aft; everything else names nothing.
- Cost: one struct field, a four-line predicate, one line in
Plan::domains. Cannot empty a domain, becauseVACUUMis compatible with everything and is never struck, so the no-backtracking collapse is not put at risk. - Effect: every nozzle on every ship points aft. This is the whole reported complaint.
- Price, measured over 12 seeds: drives 258 -> 74. That is not a rounding error, it is 70% of the engines, and the reason is structural rather than a tuning miss: a drive has one socket and five blind faces, so it can only stand where EXACTLY ONE neighbour is solid, and fixing that neighbour to be the forward one means it can only stand on an aft-facing proud surface. On a hull whose only aft-facing surface is the transom, there are very few such cells. Raising the weight alone does not buy them back: at ten times the weight and no seed, the row still came back with 40 drives over 12 ships. Supply, not price, is the binding constraint - which is exactly why the next item is not optional.
2. Seed the drive deck, the way the keel is seeded. SHIPPED
seed_stern hand-collapses the stern before the roll: a deck plate the size of
the drive’s mount face, and the drive bolted to its aft face. The mirror makes
that a pair either side of the centreline. seed_keel stops one cell short of
the transom so the seam cell beside the drive is free - a keel cube there would
press a socket into the drive’s blind flank.
It grew past the two cells proposed here: the drive is named by the plan
(WfcKeel::stern_drive) and may be several cells on a side, so the seed
lays the block by its minimum corner and propagation fills the rest of it in.
runnable refuses a grid too small to hold what the plan seeds, by name,
rather than letting the seed run off the end.
- Cost: fifteen lines, in the shape of the function above it.
- Effect: every ship has an engine at the back of it, guaranteed, and the seeded pair MAKES the aft-facing surface the roll then fills the transom around. Item 1 says where a drive may not go; it cannot conjure the place where it may. The two are one change.
- Measured, 12 seeds, with the drive weight taken 3.2 -> 6.4 to pay for the rule: hull 1774 -> 2000, drives 258 -> 102, bays 96 -> 74, mounts 92 -> 62, bridges 46 -> 52. Per ship that is 21.5 scattered drives becoming 8.5 in one stern bank. Weapons are down about a third, which is a weight question and the note under item 6.
- Rendered at a matched camera over the default row. The ships gained a readable stern: a bank of nozzles all firing the same way, with the hull running forward from it. This is the single biggest change to how the row reads of anything in this note.
3. Give the hull a deck and a belly. MEASURED, NOT LANDED
It would be two more WfcVacuum fields today, not two consts.
off_keel is symmetric in y, so a ship has no top. Weight the two directions
differently - |y - keel_row| times a deck taper above and a belly taper below -
and the hull fills out above the keel and tapers under it.
- Cost: one line and two constants.
- Effect, measured over 12 seeds at 0.6 / 1.6: hull 1774 -> 1968, drives 258 -> 212, bays 96 -> 78, mounts 92 -> 90. Rendered, and I did not land it. It makes the ships denser and blockier rather than more oriented: one of the three read distinctly better, one read as a brick, and the owner has said on the record that the pointy, faceted vocabulary is wanted. A silhouette change is a taste call for the owner, not a bug fix, and this one is not clearly an improvement. Left here with its numbers so the next person does not have to measure it again.
- If it IS wanted, the honest version is probably not this: it is a
y-dependent keel row, or a superstructure seeded on top of the keel the way item 2 seeds the stern. Which is item 4.
4. Seed a superstructure, not just a bridge cell. NOT LANDED
seed_keel puts keel.bridge at the keel row, mid-height, buried inside
the hull. A bridge that reads is a bridge you can SEE: on top, forward of centre,
standing proud.
- Cost: a second seeded run of cells, in
seed_stern’s shape - two or three hull cells aty = HEIGHT - 1aroundz = LENGTH / 3, with the controller on top of them. - Effect: a ship gains a recognisable island, which with the derived skin becomes a plated superstructure rather than a lump.
- Risk, and it was real: a seeded tower is a stud, and
erode_studsexists to take those off.runnow marks every cell the seed passes write, widens the mark over a part’s joints, anderode_studsskips it - so a tower seeded the way the stern is inherits THAT exemption rather than needing its own. It inherits nothing else:keel_componentanderode_blocked_exitsdrop parts through the samedrop_partand never read the mark. The stern deck survives the keel component only because it sits beside the keel, and both seeded lanes exit the grid; a tower aty = HEIGHT - 1reaches the keel through cells the roll placed, so it needs its own answer there.
5. Zone the grid, and give each zone its own part list. MECHANISM SHIPPED
The general form of items 1-4: split z into bow / midships / engineering bands
(the vacuum taper already computes the signal) and ban part families per band -
no drives forward of the engineering band, no bays in it, bridges only in
midships-top. Same mechanism as item 1, a table instead of a formula.
Shipped as WfcPart::zone, with six regions rather than three bands: thirds
along the hull (Bow, Amidships, Stern), halves either side of the keel row
(Dorsal, Ventral, and the keel row itself is neither), and the outboard half
(Flank). It rules on one CELL, so a multi-cell part is zoned only where every
cell of it qualifies. standard_hull authors no zone at all - the mechanism
is there for a caller that wants it, and the caution below is why the shipped
plan has not spent it.
The caution turned out to be right in a way not anticipated here: a zone CAN empty a domain, which nothing else in this note can. Not through the unary filter itself - vacuum survives that - but across a JOINT, where vacuum is not an option and only the partner segment fits. Zone a multi-cell part into a region too short to hold it and the cell past the boundary has nothing left. The collapse refuses that plan by name; it does not photograph it.
- Cost: a band function and a table. Maybe forty lines.
- Effect: the ships get an internal LAYOUT rather than a uniform texture, which is the difference between “a hull with parts on it” and “a hull with a bow, a waist and an engineering section”.
- Do this only if items 1-4 are not enough. Every band is more domain filtering, and filtering shrinks the solution space while local propagation still cannot see a dead end coming. This collapse has no backtracking. If bands start producing contradictions the answer is modifying-in-blocks per band, and that is a real piece of work.
6. Re-tune the weights, once, after all of the above. NOT DONE
The record already says weights have to be tuned together and read together, because the parts compete with each other rather than only with vacuum. Items 1 and 2 changed the competition: drives now bid for a small set of aft cells instead of every exposed face, so the surface freed up went to hull. Bays and mounts came down a third and nobody asked them to.
- Cost: a sweep, and the harness for it exists (
--ships 12prints the histogram). - Do it LAST. Tuning weights against a layout that is about to change is wasted work, and it is how the previous round of numbers got measured twice.
- The weights are PLAN fields now (
WfcPart::weight,WfcVacuum), so a sweep tunesstandard_hullrather than the generator, and a caller that wants a different row builds a different plan from it.
5. What I would NOT do
Do not put the fact in the tileset. It is tempting to author two thruster
prototypes, an “aft drive” and a “manoeuvring thruster”, and let mating sort them
out. It cannot: mating is binary and the distinction is global, so both would
still land anywhere. It would also put a generator concern into shipped catalog
content that the editor and every scenario have to carry. The WfcPlan is where
that fact went instead - code beside the collapse, built fresh by each caller,
and read by nothing else.
Do not add a scoring-and-rejection pass. Generate N ships, score each for “engines at the back, bridge on top”, keep the best. It is the obvious answer and it is a trap: the cost is N collapses per ship, the failure mode is that the SCORER quietly becomes the real designer while the generator’s own rules stop being the thing that produces the design, and any weighting inside it is invisible in a way a per-part field is not. The one practitioner example I found of scoring generated output optimises the generator’s PARAMETERS rather than picking among outputs, which is a different and much cheaper shape. If we ever want this, that is the version to want.
Do not rewrite the collapse as a grammar or a graph rewrite. It would express front/back/top naturally, and it would throw away the thing this generator is actually for: the adjacency rules ARE the catalog’s link points, so a hull the generator draws is a hull a player could have built. That property is the whole point of the example and no amount of layout sense is worth it.
Do not make the exit-clearance rule directional. It is correct as it stands,
it is shared with the editor, and a version of it that knows about “aft” would
put a generator’s taste inside a rule a player’s ship is judged by. Aim belongs
in Part, where it is one file’s opinion; clearance belongs in nova_ship,
where it is physics.
Do not reach for backtracking. Every unary filter above is safe on its own, because vacuum is compatible with everything and is never struck. That property is worth defending: a collapse that cannot fail needs no restart loop, no retry counter and no failure budget. Any proposal that costs it should have to argue for itself - and note that item 5 already costs a little of it, because a joint face has no vacuum option. The answer there was to REFUSE the plan with a line naming what to change, not to start retrying seeds.
Sources
Verified from the primary source:
- Boris the Brave, Driven WaveFunctionCollapse - https://www.boristhebrave.com/2021/06/06/driven-wavefunctioncollapse/
- Boris the Brave, WFC Tips and Tricks (fixed tiles, biome filtering) - https://www.boristhebrave.com/2020/02/08/wave-function-collapse-tips-and-tricks/
- Boris the Brave, Arc Consistency Explained (unary constraints are domain filters) - https://www.boristhebrave.com/2021/08/30/arc-consistency-explained/
- Boris the Brave, Constraint-Based Tile Generators (hard rules versus the soft heuristic) - https://www.boristhebrave.com/2021/10/31/constraint-based-tile-generators/
- Boris the Brave, Model Synthesis and Modifying in Blocks - https://www.boristhebrave.com/2021/10/26/model-synthesis-and-modifying-in-blocks/
- Paul Merrell, model synthesis and graph grammars - https://paulmerrell.org/model-synthesis/, https://paulmerrell.org/grammar/
- gridbugs, Wave Function Collapse (the weighted-entropy formula) - https://www.gridbugs.org/wave-function-collapse/
- mxgmn/WaveFunctionCollapse (README: “the algorithm doesn’t know about global structure”) - https://github.com/mxgmn/WaveFunctionCollapse
- a1studmuffin/SpaceshipGenerator, MIT - https://github.com/a1studmuffin/SpaceshipGenerator
- Oskar Stalberg, Wave Function Collapse in Bad North, EPC2018 - https://www.youtube.com/watch?v=0bcZb-SsnrA