Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Architecture

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

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

Crate map

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

| nova_modding | Bundle/content/catalog ASSET LOADERS and the Content routing enum. See Mod files. | | nova_mod_format | Pure serde types for the mod formats (bundle manifests, catalog declarations, the portal wire schema). Engine-free; re-exported by nova_modding. The static mod portal is built by scripts/gen-portal.py, not a crate. See Publish a mod. | | nova_training | The training handbook’s DATA, a leaf under everything that teaches: the Lesson content type and its catalog ordering, the field notes the menu and both loading screens draw, the progress record, and the pure validate both content lint and the runtime merge run. It knows nothing about drawing a lesson or loading one - nova_modding routes Content::Lesson, nova_assets merges the catalog, nova_menu draws it, and nova_authoring builds the base set. Its only game dependency is nova_gameplay, for the AssetRef<Image> a lesson’s media resolves through. | | nova_input | The bindings registry, a leaf crate under every rig and every rebind surface: the one table (InputBindings) that says which named actions exist, what each is called on screen, and which physical sources it holds, plus the shared capture (poll::InputSources) every rebind row reads and the by-name dispatch. Owners register their own defaults into it; nothing here knows what an action DOES. | | nova_ui | Shared UI, a leaf crate everything that renders UI draws from: the UI THEME (theme::*: the authored UiThemeConfig format, the inheritance resolver, the two base themes the base mod ships as content, and the GameUiThemes/SelectedUiTheme/ActiveUiTheme registry every widget paints from), the themed widgets (widget: button, slider, segmented control, list rows, panel chrome), screen-level composition (screen: scrollable viewports and the list-beside-details layout the menu screens and the interface share), the flight-HUD chip language (hud), player-facing unit formatting (units), the shared typeface (font), the generic status_bar and the keyboard-ownership arbiter (input_mode: one app-global InputMode resolved from per-frame claims, with InputModeSystems as the ordering handle every keyboard consumer gates behind). Consumed by nova_gameplay, nova_hud, nova_interface, nova_menu, nova_editor and nova_assets. | | nova_debug | Debug-only plugin (inspector, overlays). Compiled only under the debug feature. | | nova_info | Exposes APP_VERSION, injected by build.rs. | | nova_autopilot | Scripted automation drivers and the run-completion protocol the harness examples share. Engine-facing but game-agnostic; nova_debug, nova_probe and nova_probe_cli all build on it. See Automation harness. | | nova_probe | Dev tooling (not in the shipped game): the IN-GAME half of the run-harness - the capability plugins an example wires to collect evidence about its own run (capabilities:: frametime, timeline, invariants, snapshot, census, framecost, all bundled by NovaProbePlugin), the contract an example declares, and the wire format the host reads. See Measuring performance and Building and running. | | nova_channel | Dev tooling (not in the shipped game): the process channel a harness drives the running game through - named input holds, aim deltas, text, raw keys, pointer moves and command lines read as JSON lines on stdin, with world snapshots and per-line acknowledgements written back on stdout. It depends on nova_command for the command LANGUAGE, never on nova_console, so the wire cannot pull gameplay into a tool that only wanted to type. See Automation harness. | | nova_probe_cli | Dev tooling: the HOST half of the run-harness - spawns autopilot runs as child processes, grades their artifacts (evaluation) and renders the reports (report). Owns the cargo run --features debug probe run/report CLI. The two halves meet at the filesystem: nothing in nova_probe reads a run’s output back. | | nova_perf_web | The wasm app probe run --platform web boots and measures: the real game started into a scenario with the frame-time capture armed. Dev tooling, never shipped. | | nova_authoring | The OFFLINE half of the content pipeline (never shipped): the Rust builders that define every built-in scenario and section, the content -- gen serializer that writes them to the committed assets/base/**/*.content.ron, and the content -- lint walk that validates a content tree. | | nova_meta_gen | Binary under tools/ (web-build tooling, not a game crate): writes default .meta sidecars for web assets that lack one (a Trunk post_build hook for AssetMetaCheck::Always). Boots a headless Bevy app, so it stays Rust. |

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

graph TD
    root["nova-protocol (root)"] --> core["nova_core"]
    core --> menu["nova_menu"]
    core --> editor["nova_editor"]
    core --> gameplay["nova_gameplay"]
    core --> ship["nova_ship"]
    core --> hud["nova_hud"]
    core --> iface["nova_interface"]
    core --> console["nova_console"]
    core --> scenario["nova_scenario"]
    core --> assets["nova_assets"]
    ship --> gameplay
    ship --> events["nova_events"]
    hud --> ship
    hud --> gameplay
    hud --> ui["nova_ui"]
    iface --> hud
    iface --> ship
    iface --> gameplay
    iface --> cmd["nova_command"]
    iface --> ui
    menu --> iface
    console --> menu
    console --> cmd
    console --> scenario
    menu --> ui
    editor --> ship
    editor --> ui
    editor --> wfc["nova_wfc"]
    wfc --> ship
    wfc --> scenario
    gameplay --> events
    gameplay --> ui
    ship --> input["nova_input"]
    hud --> input
    iface --> input
    menu --> input
    editor --> input
    scenario --> input
    scenario --> events
    scenario --> gameplay
    scenario --> ship
    scenario --> hud
    assets --> modding["nova_modding"]
    assets --> scenario
    assets --> training["nova_training"]
    modding --> modfmt["nova_mod_format"]
    modding --> training
    menu --> training
    training --> gameplay
    core --> worldbase["nova_world_base"]
    menu --> worldbase
    worldbase --> world["nova_world"]
    worldbase --> scenario
    worldbase --> ship
    world --> scenario
    world --> gameplay
    world --> assets
    world --> events
    core --> debug["nova_debug"]
    core --> info["nova_info"]

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

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

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

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

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

Generic helpers live here too

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

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

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

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

Units and scale

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

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

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

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

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

App assembly

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

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

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

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

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

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

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

States

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

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

stateDiagram-v2
    state "GameStates" as GS {
        [*] --> Loading
        Loading --> MainMenu: menu app
        Loading --> Playing: custom game plugins
        MainMenu --> Playing: New Game Create / Sandbox
        Playing --> Loading: leaving gameplay (content restart)
        state "Playing" as Playing {
            [*] --> Unpaused
            Unpaused --> Paused: ESC / focus lost / outcome
            Paused --> Unpaused: ESC
            Unpaused --> Interface: Tab
            Interface --> Unpaused: Tab / ESC
            Unpaused --> Commands: :
            Commands --> Unpaused: ESC
            Paused --> Commands: :
            Commands --> Paused: ESC
            Interface --> Commands: :
            Commands --> Interface: ESC
        }
    }

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

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

Leaving Playing resets PauseStates back to Unpaused.

Frame flow

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

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

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

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

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

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

    render --> fixed --> post

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

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

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

Which schedule:

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

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

The fixed loop runs on the SINGLE-THREADED executor (AppBuilder::assemble), so a FixedUpdate system gets no parallelism from its neighbours - only from its own par_iter. That is a deliberate trade for the schedules’ size; the number behind it is in Measuring performance. The same method caps the compute task pool at nova_core::MAX_COMPUTE_WORKERS threads, so the parallelism a par_iter or a PostUpdate system can reach is bounded too (why).

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

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

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

Assets

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

Find it in the code

  • App assembly, plugin order: AppBuilder - crates/nova_core/src/lib.rs; game binary and CLI flags - src/main.rs.
  • States: GameStates, PauseStates, GameMode - crates/nova_gameplay/src/lib.rs; ESC overlay and clock freeze - crates/nova_menu/src/pause.rs.
  • The command modal and the TAB interface: the terminal model - crates/nova_command/src/terminal/state.rs; the command catalog, parser and CommandChannel - crates/nova_command/src/commands.rs; the dispatcher that runs them against the world - crates/nova_console/src/dispatch.rs; the : gesture - open_command_shell in crates/nova_menu/src/pause.rs; the interface’s open, close and pane switch - crates/nova_interface/src/pane.rs; the shared back-out owner - crates/nova_interface/src/terminal/input.rs.
  • Frame-flow sets: SpaceshipSystems - crates/nova_gameplay/src/plugin.rs; chained in Update + FixedUpdate by NovaShipPlugin - crates/nova_ship/src/lib.rs.
  • Who owns the keyboard: InputMode, ClaimKeyboard and InputModeSystems - crates/nova_ui/src/input_mode.rs; the editor’s claimant - declare_editor_keyboard_owner in crates/nova_editor/src/lib.rs.
  • The two damage readings: DamageLevel - crates/nova_gameplay/src/integrity/erosion.rs; DamageMarks and the carve cost model - crates/nova_gameplay/src/integrity/carve.rs.
  • Asset gate and mod merge: GameAssetsPlugin - crates/nova_assets/src/plugin.rs; register_bundles - crates/nova_assets/src/merge.rs.
  • The training handbook: the lesson format and catalog ordering - crates/nova_training/src/catalog.rs; the validation both content lint and the merge run - crates/nova_training/src/validate.rs; the screen - crates/nova_menu/src/training.rs; the progress record and what a won scenario proves - crates/nova_menu/src/training_store.rs; the base lessons, their practice ranges and what each one is proven_by - crates/nova_authoring/src/base_content/lessons.rs and .../scenarios/drills.rs. A lesson’s wiki_path points into the web manual under web/src/, and crates/nova_authoring/tests/lesson_wiki_links.rs fails if a page or heading it names stops existing.
  • API detail: cargo doc --open -p nova_core (any crate from the map works).