Architecture
New to the codebase? Start with the Project tour for a faster orientation, then come back here for the detail.
Nova Protocol is a 3D space shooter built on Bevy 0.19 with avian3d physics.
It is a Cargo workspace: the root nova-protocol crate is a thin shell and all the
real code lives under crates/.
Crate map
| Crate | Responsibility |
|---|---|
nova-protocol (root) | src/main.rs = clap CLI + entrypoint. src/lib.rs re-exports nova_core. Runnable examples in examples/. |
nova_core | Thin wiring only: AppBuilder assembles every plugin (window/log/asset setup, status UI). No gameplay logic. |
nova_menu | Main menu (owns the MainMenu state UI: New Game / Sandbox / Settings / Exit) and the ESC pause overlay. Buttons write GameMode and hand off to Playing. New Game first opens the world setup modal (world_setup): a seed drawn from the game’s entropy, and only its Create inserts OpenWorldSession and hands off. The Settings modal (audio volume, graphics preset, UI theme, and the Controls tab that REBINDS every action in nova_input, one binding group at a time) is shared by both entry points. Persistence is NOT the menu’s: settings_store owns SettingsStorePlugin, which nova_core adds to every app, and it reads the same cross-platform store (RON file / localStorage), keybind overrides included. The two directions are split: every app READS, and only an app that builds the settings panel calls allow_settings_saves and earns the write direction. What the player has LEARNED is a second file beside it, training_store: opening a lesson reads it, and only a scenario Victory that a lesson’s proven_by names completes it. Same root, same access split, separate file - deleting settings.ron to fix your audio must not erase your progress. |
nova_editor | The ship editor scene (NovaEditorPlugin). Comes up on entering Playing, only in GameMode::Sandbox. |
nova_gameplay | The shared gameplay layer under the ship: integrity/ (health, the two damage readings erosion and carve, and the debris a carve leaves in spew/chunk), damage, gravity (gravity wells), markers (the entity markers the ship tags with and this layer reads), math, audio (the bus-and-route sound engine every voice in the game goes through: bus for the four routes and the three volume tracks, mixing for the distance rolloff and the cue throttle, spatial for the stereo placement, voice for the one playback path), juice, shake, settings (MasterVolume/GraphicsQuality + apply systems; the per-bus InterfaceVolume/WorldVolume/MusicVolume live in audio/bus), mesh (the procedural TriangleMeshBuilder, plus the SignedField an asteroid is meshed from and carved in - nothing here takes a finished mesh apart), transform, relations, beacon, objectives (the GameObjectives list, its panel and the conveyance tags), lifetime (TempEntity/DespawnEntity), cooldown, plugin. Also owns GameStates, PauseStates, and the GameMode resource. Knows nothing about a ship. |
nova_ship | The ship and how it is flown: sections/ (the modular hull, its ammo, and the authored damage looks in damage_effects/damage_cracks/damage_sparks/damage_plume), input/ (player rigs, the AI pilot and gunner, radar targeting with deliberate lock-on, and the flight and camera action DEFAULTS it registers into nova_input), flight/ (the diegetic controller and its autopilot verbs), camera/ (the chase-camera controller and the chase/skybox/post/WASD rigs under it), physics/ (the PD attitude controller) and ship_audio/ (the soundtrack those five produce). Depends on nova_gameplay and never the reverse; NovaShipPlugin owns the SpaceshipSystems brackets and nova_core adds it after NovaGameplayPlugin. |
nova_wfc | Generated hulls: a function from a section catalog and a code-owned WfcPlan to a ShipDesign, with no App and no systems in it. Owns the plan types too (WfcGrid, WfcVacuum, WfcKeel, WfcPart), because which sections one generator may reach for is generator policy and not mod content. TileSet::build reads the plan against the catalog and builds tiles only for parts nova_ship::cell_grid_fit accepts, each read at the 24 cube rotations through nova_ship::oriented_part, hull() collapses one seed, and every hull is put through the game’s own content lint before it is handed back. Each caller builds its own plan from WfcPlan::standard_hull and its own UI rows: nova_editor’s Generate verb, and the wfc_* examples. |
nova_hud | The flight HUD: one module per widget (crosshairs, target inset, ammo readout, flight status, objective markers, the comms panel, the keybind dock, the screen-indicator projection they all share). Reads gameplay state and never drives it, so the dependency runs nova_hud -> nova_gameplay. nova_core adds NovaHudPlugin unconditionally, headless runs included - the widgets register their keybindings in build, so a gate took those bindings out of the registry. The crate places NovaHudSystems between the section and camera sets itself. |
nova_command | The game command language and the NOVA COMMANDS terminal model, with no UI in it: the terminal (terminal: prompt, scrollback, completion), the matcher and typo suggestions (shell), and the curated catalog, parser and CommandChannel (commands). |
nova_interface | The TAB interface and the : modal. pane opens the full-screen interface over the frozen world with a map pane (schematic local space, contacts, GOTO) and a ship pane (schematic player ship, repair, rebind, mates); viewer is the orbit camera both run on. terminal is the retained CRT modal: the casing and shader, the terminal nodes, the keyboard and pointer systems, the one back-out owner for both surfaces, and the flight-log model. A PEER of the flight HUD, not one of its widgets: nova_core adds it, and nothing in nova_hud reaches into it (it reads NovaHudAssets and NovaHudSystems, so it sits ABOVE nova_hud). |
nova_console | The command shell’s dispatcher: the executor behind the CRT’s cmd> prompt and the channel’s command lane. nova_command owns the LANGUAGE (catalog metadata, parser, CommandChannel) and stays a leaf; this crate owns the half that touches the world - inspection, the flight log, the persisted settings, and the armed cheats. It sits ABOVE nova_menu on purpose: a graphics or volume command writes the very resources the settings screen owns, and one of them has to be downstream. |
nova_scenario | Scenario/modding engine: events, filters, actions, variables, world, loader, objects/, lint/ (the scenario half of the content -- lint checks), render_scale (the Low-preset resolution lever: scenario view into a reduced offscreen target, upscaled to the window). See Scenario engine. |
nova_world | The streamed open world’s mechanism. It names no content except the identity a generated ship carries, CivilizationId (with its seeded name and per-aspect stream) and the four ShipRoleType roles, which it owns as the lowest crate a manifest and its generator share. AppBuilder does not add it directly: the game gets NovaWorldPlugin<NovaLayeredWorld> through nova_world_base. Examples that bring their own game plugins install their own generator - the playable world_sectors, world_features and world_clusters, and the system_world_sectors correctness range; the two field pictures (world_field_slices, world_field_clouds) sample nova_world_base‘s environment fields directly, and world_civilizations paints its civilization field; none of the three streams anything. Nor does the playable world_sector_inspector: it prepares one cell at a time with prepare_sector and spawns it with materialize_sector, with no WorldObserver, handing it a bodiless observer point two cell edges past the cell’s centre on every axis, so no ship is ever held wherever the free camera stands. An explicit WorldConfig<G> (there is no Default - a seed, a cell edge and a generator are named or nothing streams) names the SectorGenerator TYPE the plugin is generic over; one app installs exactly one. The generator fills a cell from its coordinate alone and returns a BODY-ONLY SectorManifest - rocks, planetoids and ships - and validate_manifest turns it into the private-field SectorDescription that preparation and materialization accept. The core enforces materialization contracts only: the requested cell, finite geometry, unique ids prefixed with the cell’s slug, shipped asteroid kinds, PlanetConfig::validate, a ship rotation that is a finite unit quaternion, a declared ship clearance that is positive and at most SECTOR_SHIP_CLEARANCE_MAX (400 m), an inline ship design with sections, unique section ids, prototype references only and finite section poses, every body’s clearance sphere wholly inside its cell, no two bodies overlapping, finite MetersPerSecond3 initial velocities for rocks and ships, and a crew present on exactly an intact ship whose patrol waypoints are all finite, one stop per waypoint, and each stop and the leash a finite positive number. The canonical description prints a ship’s rotation sign-normalized, so q and -q describe one sector, and prints its civilization, role, design integrity, design presentation and each section’s prototype patch through Debug, so a non-finite value never prints as None. It sets no body count limit; density belongs to the generator. Its shared primitives are stateless: WorldGeometry::require_owning_edge takes the generator’s own placement inset, and bodies_clear takes the generator’s own margin (the core check passes zero). Where each object stands, its inset, spacing, retries and ids belong to the generator. The uniform baseline lives in the examples’ world fixture with its own inset, margin and body count, beside the clustered generator, whose asteroid-rich, rock-only, planet-heavy, derelict-only and low-rock groups are decided on a global lattice from three environment fields and owned body by body by the cell each centre falls in, so a group crosses cell faces. It lays out its hulls through nova_world_base’s plan_ship from the ship parts of the loaded catalog. streaming runs the lifetime in six ordered NovaWorldSystems stages (Cleanup, Observe, Request, Collect, Materialize, Retire) around ONE live scenario, clearing every cell when the scenario or the config changes, preparing cells off the frame and spawning them through nova_scenario’s own object factories. Before a cell’s root spawns, every ship’s inline design is resolved strictly against the loaded GameSections, and any resolver error panics with SectorFault::InvalidShipDesign, because the spawn’s own resolver skips a broken section. Every mobile rock and ship spawns with its manifest velocity in meters per second, converted at the engine boundary. Every ship spawns with its manifest stock in its hold and its manifest credits as its balance, named <civilization> <role> or <civilization> derelict, former <role>. Inspection and the selected map contact show that name; a distant map blip shows only its minted contact code, and the map lists no contact before its code is minted. An intact ship spawns with an AI crew flying its civilization’s own allegiance (Enemy, Player or Neutral) with every system live: it flies the manifest’s waypoint loop, holding at each stop before the next leg, chases no farther than its leash, and an Enemy crew holds fire through its engage_delay grace. A derelict spawns unpiloted and Neutral, and also carries DerelictShipMarker, so its hull and docking ports stay live, every other section spawns inactive and detect_neutralized never neutralizes it, and LootableShipMarker, so a docked ship may Take its stock or its whole credit balance. A ship whose clearance sphere the WorldObserver’s body (its IntegrityEnvelope, or zero without one) overlaps is held as a PendingSectorShip child of the cell’s root and spawned unchanged by materialize_pending_ships once the observer is clear; retiring the cell takes it with the root. Every root is scenario-SCOPED, so unloading the session takes the world with it, and no streamed entity is scenario-ADDRESSABLE. Invalid content refuses with a SectorFault instead of materializing a partial cell. |
nova_world_base | The base game’s open world over nova_world: the complete concrete NovaLayeredWorld generator, the OpenWorldSession seed New Game writes, and the stable ids the world and its open_world bootstrap share. The generator reads three independent global environment fields (material density, volatiles, human activity) and decides, at each node of a 50 km lattice jittered by up to 10 km, one asteroid-rich, rock-only, planet-heavy or derelict-field cluster or none, each reaching at most 8 km from its anchor. A planet-heavy cluster stands two to four worlds at the corners of a pair, a triangle or a tetrahedron, their bodies kept CLEARANCE_MARGIN apart while their wells may overlap; a derelict field holds rocks around its hulls. A cell replays every node whose cluster can reach it and owns each body whose centre it holds, so a cluster crosses faces and every cell agrees about it. A cell resolves every cluster’s planetoids, then its rocks, then its hulls. A body whose clearance sphere crosses its cell’s face, or that comes within the 500 m CLEARANCE_MARGIN of a body placed before it in that cell, is skipped and counted; a planetoid that cannot be placed refuses the cell with SectorFault::Generation. A hull is placed only in a cell where its own cluster placed a rock or planetoid; otherwise the cell tries the escort rocks the cluster drew on the anchor side of that hull, places the first that fits and counts each one it tried, or skips the hull as having no companion. Escorts count toward each type’s derived extent, the halo and the gap between clusters, and a cell never looks past its faces for one. Overlap retries translate a placed escort and its hull together, keeping them 1,650-2,200 m apart; an impossible retry faults the sector. Every planetoid authors the mass that gives it a sphere of influence of 3.5 outer body radii under the default GravitySettings, and remains a static well. A cell that owns no cluster body may draw a background scatter of one to five rocks. The generator refuses an edge wider than 128 km or too narrow for its widest core of planetoids or a scatter, a world spacing or escort distance too short to clear the bodies it separates, bands that derive a cluster past its extent limit, and a planetoid reach whose surface pull passes the default surface gravity cap. sector_clusters and EnvironmentFields are public so debug examples read the same plan and fields; no streamed entity carries them. CivilizationField is the provisional civilization field that ship selection reads; the world_civilizations diagnostic reads it too. One civilization stands at each node of a 240 km lattice jittered by up to 40 km per axis. Its identity is the world seed plus the signed node, never its generated name. Its centroid, living or extinct status, advancement, role preference and allegiance (Enemy, Player or Neutral, one chance in three each, independent of the others) each come from their own seeded stream, so they do not depend on visit order. Influence is 1 at the centroid, 0.5 at 160 km and reaches zero only at 320 km, with no border between civilizations. The lattice bound keeps every place within reach of a centroid, and a place with none refuses with SectorFault::Generation. A place farther from the origin than a centroid gets up to four times its selection weight. Advancement rises with the centroid’s distance from the origin to saturation at 1,000 km, plus a seeded offset of up to 0.1. role_weights gives the four roles’ weights before content eligibility. ShipPartSnapshot is the provisional part catalog generated ships are built from. The open world builds one from LoadedSectionPacks when it arms and pins it in NovaLayeredWorld with the catalog digest, and every generated ship is laid out from it. plan_ship turns each hull a cell owns into a ship before the cell’s spatial checks: a primary civilization per cluster from those in reach of its anchor by selection weight, a 10% chance for each later hull to belong to another civilization in reach by influence, a role from the civilization’s role weights at the cluster’s environment among the roles its advancement can build (read through the provisional SHIP_ADVANCEMENT_CURVE, Linear), then generate_ship for a living civilization or generate_wreck for an extinct one. The planned ship carries the civilization’s id and the role to its manifest entry. An intact ship also gets a crew carrying its civilization’s allegiance; after the cell resolves bodies and relocates overlaps, plan_patrols gives each crew a loop of up to four seeded waypoints 1,500 m around the ship in its level plane, each kept only where the ship’s whole clearance fits the cell and clears every other placed body by CLEARANCE_MARGIN - fewer than two that fit leaves the ship holding its spawn point instead, counted in SectorClusters::routeless_ships. A derelict gets no crew. Every ship’s hold and credit balance come from ship_stock and ship_credits, each from its own stream keyed like the ship and scaled by the civilization’s advancement: a role-specific item mix toward a seeded share of what its hull sections hold at HULL_SECTION_CARGO_G each, a wreck’s toward roughly half that share of its remaining hull drawn apart from the intact ship’s, and a hold that cannot fit one unit of the role’s lightest item fails the cell; a wreck’s credits keep a seeded share of an intact balance at the same advancement, at least one credit. A retired cell comes back with its ships’ stock and credits whole; nothing saves looting or spending yet. The generator gives mobile bodies a seeded tangent only for a unique well whose full orbit fits the clear, unfaded band in its owning sector; otherwise it gives an unsafe single-well start an outward escape, free space an explicit zero coast, or fails when an overlap has no safe relocation. An intact ship stands level on its nearest planetoid’s outward direction, or on +Y with none, turned by the cluster’s seeded yaw; a wreck takes a seeded uniform 3D orientation. Overlap relocation preserves a ship’s rotation, even if its nearest well changes. Every draw is keyed by the world seed, the cluster node and the hull’s slot, so a ship is the same from every cell. A planned hull reserves SECTOR_SHIP_CLEARANCE_MAX in the cluster’s extent and halo; the cell’s checks use the ship’s own root-centred clearance. A layout or content fault fails the cell: a sector fault panics streaming, so a ship that no layout fits crashes the world where it stands. The static content lint and the world_ships debug example build their own from the same repo bundle packs. It takes the base and mod section packs in any order, sorts packs and ids, and allows a section overlay only along a dependency chain, where the most dependent pack wins. It refuses non-finite or non-positive part stats, a docking capture angle outside 0 to 90 degrees, a socketed weapon or thruster that fires between two axes, a socketed weapon, thruster, cargo intake or docking port with a socket on the face it fires, opens or docks through, and a missing hull, controller, thruster, cargo intake or docking port, and reports every fault. It excludes parts without link points, uncharged railguns and parts that fail nova_ship::cell_grid_fit for any other reason, then scores each family (hull health per volume, thrust, torque, weapon damage per second, intake aperture, docking capture distance in engine units, mining carve volume per second) and normalizes it log-linearly: the weakest usable part needs advancement 0, the strongest 1. eligible groups a role’s parts by source so each pack weighs the same; civilian and industrial ships get no weapon, and only an industrial ship gets a mining beam. content_hash is a versioned FNV-1a 64 of the sorted effective catalog. generate_ship is the provisional shape-first ship layout. world_ships poses every role at its lowest eligible and full advancement with each drive layout beside four standard-plan WFC hulls, flies each intact ship, stands one generate_wreck derelict per role with every active section disabled in place, no controller and Neutral allegiance, checks that commands, a canister and a hit leave it inert but damageable, and reports the snapshot hash, each ship’s mass, thrust per cell and hull and weapon parts, and the missing weathered section art. It strips the generated skin so hulls compare bare; --skin or C restores it, and the harness also shoots the low and high rows clad. Clad WFC hulls name no style and wear the undressed derivation. It builds one ShipDesign from a snapshot, a role, an advancement and a hull seed: a mirror-symmetric spine of structural cubes from a one-cell nose to a stern that holds a bank of one drive (a centre column with mirrored pairs, or pairs only), a cross-section that changes along the length, flight computers in centreline cells inside the spine, each mounted on the hull surface instead where no flight computer part fits its cell, the role’s weapons or cargo intake, and docking ports: a mirrored pair out of the hull’s sides where one fits, else one port alone. It wears the derived skin in its role’s base style (role_style_id): civilian civilian, industrial industrial, scavenger salvage, armored armoured. Those ids are nova_world_base constants the style builders share. The spine mixes up to three structural cube types in rings, decks or a shell. It prefers one source per hull, except for scavengers. Civilian and industrial ships carry no weapon; scavengers get 1 to 3 weapon slots and armored ships 1 to 5, rising with advancement. An eligible industrial request draws 0, 1 or 2 mining beams instead, the chance and the pair threshold both rising with advancement; a drawn count with no legal mount fails the request rather than flying with fewer. Drives and weapons draw parts that score within 0.5 below the ship’s advancement before weaker parts of the same source, and each weapon slot then prefers a part not yet mounted. An industrial intake and every docking port open to starboard or port with structural cubes across their backs. An industrial spine holds one flat body run, at least one cell wide, long and tall enough for its first ranked intake’s mirrored pair beside its first ranked docking port; a drawn body with no such run has its first body stations flattened, and the spine never shrinks below the length that holds the run. A layout whose drives give less than 0.06 thrust per cell of collider mass at density 1, or that has fewer than one controller per 50 cells, is rebuilt with a larger bank and more controllers. Section ids name planned slots. Every layout is checked for unique ids, eligible snapshot parts, the role’s families, mated contacts, symmetry but for a lone docking port, backed side intakes and docking ports, one socket graph (derive_link_point_graph), clear exit and docking lanes (blocked_exits), the thrust and controller floors, a clearance ceiling from 80 m at advancement 0 to 400 m at 1, and one off-centre outer structural cube outside every docking port’s back that a wreck could omit alone (Unruinable), so every generated ship can be ruined. After 8 failed seeded attempts it returns a ShipLayoutFailure with the seed, civilization, role and last constraint. generate_wreck ruins the same request’s intact design: it keeps the role, style, source, advancement, fittings and cell bounds, and omits seeded breaches of structural cubes, each grown inward from the outer hull, that keep one socket graph, clear lanes, the ceiling and the cubes across every docking port’s back. Each of 8 omission plans digs toward a fifth of the cubes (at least 3) off the mirror image, and the first to get there wins. Otherwise the plan with the most wins if it reaches the hull’s floor: as many mirror pairs of off-centre outer cubes as can each go alone, capped at that target and at what one deterministic pass in cell order omits together. Every generated hull has at least one. When all 8 plans fall short, that pass is the wreck; a pass that fails a wreck check fails the request, and nothing rerolls or skips it. No weathered or damaged part exists, so holes are its only ruin cue. NovaWorldBasePlugin, added by AppBuilder on the default game path after the scenario plugin, installs NovaWorldPlugin<NovaLayeredWorld> and one exclusive Update system ordered before NovaWorldSystems::Cleanup: it arms WorldConfig and puts WorldObserver on the player ship only while an OpenWorld scenario is live with exactly one player ship, and removes the config everywhere else. An armed world keeps its generator; two configs compare by catalog digest and snapshot hash only. A missing session, a second player ship, no LoadedSectionPacks or GameStyles, ship parts that do not build a snapshot, a role style that no loaded style authors (ShipPartFault::MissingRoleStyle), or a catalog digest that changes under an armed world panics, the last before any config is written. The same build and platform give the same pristine sectors from one seed in any visit order; nothing a player changes is kept, so a retired sector comes back as generated. |
nova_events | Game event kinds and entity identity components, shared between gameplay and scenario. Also the world’s scale: the Meters/MetersPerSecond/MetersPerSecondSquared/Meters3/MetersPerSecond3 quantity types and METERS_PER_UNIT (units), and LOAD_LIMIT (scale). See Units and scale. |
nova_events_macros | Procedural macros behind nova_events’ derives. |
nova_assets | bevy_asset_loader setup. Loads glb/textures/shaders/sounds, and loads the base game’s own generated content (assets/base/) through the same bundle machinery as mods. Owns the mod merge (register_bundles, EnabledMods, ModCatalog) and LoadedSectionPacks (merged sections and versioned ContentCatalogDigest of effective content and mod metadata, independent of map/merge order; rejects non-finite values and excludes raw resource bytes), safe mode (safe_mode.rs: the optional half of the installed set loads outside the boot gate, and a broken mod is disabled, persisted off and reported; the merge runs lint_section_config on every section of every enabled bundle before it publishes, so an error in the base bundle inserts FatalAssetFailure and ends in GameAssetsStates::Failed, and an error in a mod quarantines that mod and every enabled mod that depends on it, except in GameStates::Playing, where an invalid mod, a failed mod load, or a dependent of a disabled mod fails the run the same way), the portal client and downloads (portal/), and prefs persistence. |
| nova_modding | Bundle/content/catalog ASSET LOADERS and the Content routing enum. See Mod files. |
| nova_mod_format | Pure serde types for the mod formats (bundle manifests, catalog declarations, the portal wire schema). Engine-free; re-exported by nova_modding. The static mod portal is built by scripts/gen-portal.py, not a crate. See Publish a mod. |
| nova_training | The training handbook’s DATA, a leaf under everything that teaches: the Lesson content type and its catalog ordering, the field notes the menu and both loading screens draw, the progress record, and the pure validate both content lint and the runtime merge run. It knows nothing about drawing a lesson or loading one - nova_modding routes Content::Lesson, nova_assets merges the catalog, nova_menu draws it, and nova_authoring builds the base set. Its only game dependency is nova_gameplay, for the AssetRef<Image> a lesson’s media resolves through. |
| nova_input | The bindings registry, a leaf crate under every rig and every rebind surface: the one table (InputBindings) that says which named actions exist, what each is called on screen, and which physical sources it holds, plus the shared capture (poll::InputSources) every rebind row reads and the by-name dispatch. Owners register their own defaults into it; nothing here knows what an action DOES. |
| nova_ui | Shared UI, a leaf crate everything that renders UI draws from: the UI THEME (theme::*: the authored UiThemeConfig format, the inheritance resolver, the two base themes the base mod ships as content, and the GameUiThemes/SelectedUiTheme/ActiveUiTheme registry every widget paints from), the themed widgets (widget: button, slider, segmented control, list rows, panel chrome), screen-level composition (screen: scrollable viewports and the list-beside-details layout the menu screens and the interface share), the flight-HUD chip language (hud), player-facing unit formatting (units), the shared typeface (font), the generic status_bar and the keyboard-ownership arbiter (input_mode: one app-global InputMode resolved from per-frame claims, with InputModeSystems as the ordering handle every keyboard consumer gates behind). Consumed by nova_gameplay, nova_hud, nova_interface, nova_menu, nova_editor and nova_assets. |
| nova_debug | Debug-only plugin (inspector, overlays). Compiled only under the debug feature. |
| nova_info | Exposes APP_VERSION, injected by build.rs. |
| nova_autopilot | Scripted automation drivers and the run-completion protocol the harness examples share. Engine-facing but game-agnostic; nova_debug, nova_probe and nova_probe_cli all build on it. See Automation harness. |
| nova_probe | Dev tooling (not in the shipped game): the IN-GAME half of the run-harness - the capability plugins an example wires to collect evidence about its own run (capabilities:: frametime, timeline, invariants, snapshot, census, framecost, all bundled by NovaProbePlugin), the contract an example declares, and the wire format the host reads. See Measuring performance and Building and running. |
| nova_channel | Dev tooling (not in the shipped game): the process channel a harness drives the running game through - named input holds, aim deltas, text, raw keys, pointer moves and command lines read as JSON lines on stdin, with world snapshots and per-line acknowledgements written back on stdout. It depends on nova_command for the command LANGUAGE, never on nova_console, so the wire cannot pull gameplay into a tool that only wanted to type. See Automation harness. |
| nova_probe_cli | Dev tooling: the HOST half of the run-harness - spawns autopilot runs as child processes, grades their artifacts (evaluation) and renders the reports (report). Owns the cargo run --features debug probe run/report CLI. The two halves meet at the filesystem: nothing in nova_probe reads a run’s output back. |
| nova_perf_web | The wasm app probe run --platform web boots and measures: the real game started into a scenario with the frame-time capture armed. Dev tooling, never shipped. |
| nova_authoring | The OFFLINE half of the content pipeline (never shipped): the Rust builders that define every built-in scenario and section, the content -- gen serializer that writes them to the committed assets/base/**/*.content.ron, and the content -- lint walk that validates a content tree. |
| nova_meta_gen | Binary under tools/ (web-build tooling, not a game crate): writes default .meta sidecars for web assets that lack one (a Trunk post_build hook for AssetMetaCheck::Always). Boots a headless Bevy app, so it stays Rust. |
The dependency layering the table describes, from top-level shell down to leaf crates:
graph TD
root["nova-protocol (root)"] --> core["nova_core"]
core --> menu["nova_menu"]
core --> editor["nova_editor"]
core --> gameplay["nova_gameplay"]
core --> ship["nova_ship"]
core --> hud["nova_hud"]
core --> iface["nova_interface"]
core --> console["nova_console"]
core --> scenario["nova_scenario"]
core --> assets["nova_assets"]
ship --> gameplay
ship --> events["nova_events"]
hud --> ship
hud --> gameplay
hud --> ui["nova_ui"]
iface --> hud
iface --> ship
iface --> gameplay
iface --> cmd["nova_command"]
iface --> ui
menu --> iface
console --> menu
console --> cmd
console --> scenario
menu --> ui
editor --> ship
editor --> ui
editor --> wfc["nova_wfc"]
wfc --> ship
wfc --> scenario
gameplay --> events
gameplay --> ui
ship --> input["nova_input"]
hud --> input
iface --> input
menu --> input
editor --> input
scenario --> input
scenario --> events
scenario --> gameplay
scenario --> ship
scenario --> hud
assets --> modding["nova_modding"]
assets --> scenario
assets --> training["nova_training"]
modding --> modfmt["nova_mod_format"]
modding --> training
menu --> training
training --> gameplay
core --> worldbase["nova_world_base"]
menu --> worldbase
worldbase --> world["nova_world"]
worldbase --> scenario
worldbase --> ship
world --> scenario
world --> gameplay
world --> assets
world --> events
core --> debug["nova_debug"]
core --> info["nova_info"]
The graph is curated for readability, not exhaustive: nova_core and nova_menu
depend on nearly every crate below them, and edges implied by the layering (for
example nova_menu -> nova_ship) are pruned. The authoritative dependency list
for any crate is its Cargo.toml. Two edges people guess wrong:
nova_uiis not menu-only. Every crate that renders UI draws on it -nova_gameplay(which addsNovaUiPluginrender-gated),nova_hud(the chip language and screen-indicator styling),nova_interface,nova_menu,nova_editorandnova_assets.nova_scenarioreaches up intonova_shipandnova_hud. It spawns ships and drives HUD-facing surfaces (comms dwell limits, target-inset render targets), so it sits beside them, not below them.
nova_world names no content, so its edges run downward only. The one game
crate above it is nova_world_base, which names base content and is what
nova_core adds; nova_authoring builds the bootstrap scenario and the
derelict hulls from the ids nova_world_base owns. An example that brings its
own game plugins gets no world unless it installs a NovaWorldPlugin itself.
The dev-tool crates hang off this graph without joining it: nova_debug builds
on nova_autopilot plus the game crates it inspects; nova_probe wires
nova_core + nova_autopilot into a measurable app; nova_probe_cli depends
only on nova_probe, nova_autopilot and nova_assets (it is a host process,
not a game plugin); nova_perf_web is nova_core + nova_probe; and
nova_authoring reads half the workspace to build and lint content offline.
Every crate exposes a pub mod prelude. Import from the prelude
(use nova_gameplay::prelude::*), not from inner modules. nova_core::prelude
re-exports all sub-crate preludes, so top-level code and examples usually just do
use nova_protocol::prelude::*.
Generic helpers live here too
The generic, non-Nova Bevy helpers (WASD/chase cameras, skybox, post-processing,
the mesh builder and the signed field, PD controller, health, status bar, the generic game-event queue
GameEventsPlugin/EventWorld) are nova’s own: the camera and transform rigs,
the mesh toolkit in nova_gameplay, the camera rigs and the PD controller in
nova_ship, the status bar and
tween in nova_ui, the event engine in nova_events, the inspector and
wireframe layers in nova_debug. They live in nova crates on purpose:
splitting a generic layer out before the game is done produces generic-looking
code shaped by one game’s needs. Whether any of it deserves extracting is a
question for after the game ships.
nova_ui::status_bar is the shape that line is drawn on. It is a generic
readout - a row of value/colour closures over any Any subject - and it stays
in the leaf crate. Nova’s own damage readout is NOT built on it: it is
diegetic, and every section fractures its own material as its DamageLevel
rises (nova_ship::sections::damage_cracks), from a vocabulary each section
AUTHORS (damage_effects). That readout keys on Nova’s section graph, its
authored vocabulary and an extended material, so it is game-specific and is not
a promotion candidate. A generic widget and a diegetic one are different things
at different layers, and the test is whether another game could take it as it
stands.
Boundary policy, from most game-agnostic to most game-specific:
- The generic-leaning modules named above - reusable Bevy primitives that happen to live in a nova crate; keep them free of game-specific types.
nova_gameplay- the shared gameplay layer, ship-agnostic.nova_ship- the ship above that layer.nova_hudandnova_interface- consumers of the ship and of gameplay, above both.nova_interfaceis abovenova_hudin turn: it orders itself againstNovaHudSystems.nova_core- wiring only.
Units and scale
Nova reasons in meters; the engine underneath does not. Authored metric
quantities, including vector velocities, and player-facing readouts use SI;
selected engine-side parameters such as gravity’s mu are exceptions below.
Bevy transforms and meshes, avian colliders and velocities, shaders and the
build grid all count in engine WORLD UNITS, and one world unit is
METERS_PER_UNIT = 10 m - so a build-grid cell is one world unit and 10 m on a
side.
The seam is the quantity types in crates/nova_events/src/units.rs - Meters,
MetersPerSecond, MetersPerSecondSquared, the Meters3 displacement, and
MetersPerSecond3 velocity, exported through that crate’s prelude. They carry
no Deref to f32, so an SI quantity cannot reach an engine API by accident,
and they cross in exactly two
directions: to_engine() on the way out to Bevy, avian, a mesh or a shader,
from_engine() on the way back. Every call site of either IS the boundary and
says so locally. AsteroidConfig::initial_velocity stays in meters per second
until its spawn path calls to_engine() for avian. Serialization is
#[serde(transparent)], so a content file
still reads blast_radius: 300.0 - the TYPE documents the unit, not a wrapper
in the file. LOAD_LIMIT (crates/nova_events/src/scale.rs) is the other half
of the world’s scale and is likewise SI, and nova_ui::units formats the
player-facing strings without converting anything, so a readout cannot pick up a
second factor of ten on its way to the screen.
Three kinds of number stay engine-side on purpose, and each says so where it is defined:
- Geometry the engine owns - a collider half extent, a signed-field cell, a
build-grid position, the answer
structural_armreads off posed colliders. These come out of avian or a mesh, so they are world units until something crosses them. - Quantities that are not a plain length. A gravitational parameter
muis L^3/T^2, so an SI one would be a THOUSAND times the world-unit one;DAMAGE_PER_UNIT_VOLUMEis hit points per cubic world unit, an INVERSE volume. Neither is “times ten”, and a silent conversion of one is a worse defect than leaving it engine-side - so they stay engine-side and quote the metric figure a reader actually wants beside them. - Dimensionless ratios - a fire-gate cone, a closing-speed multiplier, an angular acceleration in rad/s^2 - which the scale does not touch at all.
The rest of this book quotes a world unit only where one of those three applies, and says “world unit” out loud when it does.
App assembly
AppBuilder (in crates/nova_core/src/lib.rs) is the single place the app is wired:
#![allow(unused)]
fn main() {
AppBuilder::new() // Bevy DefaultPlugins + window/log/asset/render setup
.with_game_plugins(my_plugin) // optional: your own systems/observers
.build() // adds the plugin stack, returns App
}
AppBuilder::headless() is the same builder with no wgpu device, no window, no
winit event loop and none of the visual game plugins the render flag gates. Rendering is fixed by the
CONSTRUCTOR rather than by a setter, because DefaultPlugins bakes the wgpu and
window settings the moment the builder starts - a later setter could not reach
them. It is one switch and not two because the halves cannot be separated:
bevy_hanabi panics without a render sub-app, so dropping the device forces
dropping the plugins that need it.
build() inits GameStates + PauseStates, then adds, in order:
EnhancedInputPlugin, NovaInputPlugin (the bindings registry is a leaf: it
holds the table every rig is built from, so it lands before any plugin that
registers an action), GameAssetsPlugin, LoadingScreenPlugin,
NovaGameplayPlugin, NovaShipPlugin (the ship orders its sets inside
gameplay’s SpaceshipSystems brackets, so it comes after), NovaScenarioPlugin,
then NovaHudPlugin and InterfacePlugin (HUD first: the interface orders itself
against NovaHudSystems). Those two are NOT render-gated - a headless run
assembles both. Each registers its bindings inside build, and under a gate a
headless run kept only 15 of the 33 registry actions; everything GPU-side in
them is already guarded by bevy (UiMaterialPlugin and friends no-op without a
render sub-app). The price is that a headless measurement run carries HUD and
interface CPU systems. Then NovaEditorPlugin, then SettingsStorePlugin (EVERY
app, menu or not, and READING only: a settings panel is where a value is edited,
not what makes it apply, so an example that never builds a menu still flies on
the player’s own sensitivities, keybinds, volumes and quality preset - while an
app with nowhere to edit one cannot write that player’s file back. from_env
makes it inert under a scripted run, and the menu below is what grants the write
direction), then NovaMenuPlugin (the editor and the menu only
when no custom game plugins were supplied - the menu fronts the default app and
nothing else, so an example that brings its own game plugins goes straight
Loading -> Playing), then NovaConsolePlugin (the Command shell’s dispatcher,
unconditional and after the menu: a graphics or volume command writes the
very resources the settings screen owns, and an example with no menu still has a
CRT to answer status and ships on), and finally DebugPlugin under the
debug feature. On OnEnter(GameAssetsStates::Loaded) it hands off to
MainMenu (or straight to Playing when the menu is off) and spawns the
status UI. That handoff runs on
every pass through Loaded, boot and content restart alike, so what separates
them is the --scenario launch request: boot_into_the_game TAKES it, and a
restart therefore finds nothing to open and lands in the menu. The status bar is
taken down again on OnExit(Loaded) - its FPS icon is a handle out of the
collection the restart is about to re-read, and insert_status_bar_item hangs
every item off a Single root, so a second root would silence the new items
rather than draw a second bar.
NovaGameplayPlugin pulls in avian3d PhysicsPlugins (zero gravity, projectile
collision hooks), bevy_rand, bevy_hanabi particles (on wasm via the WebGPU
backend), NovaUiPlugin (render-gated), the transform/lifetime/mesh rigs, and
the shared gameplay sub-plugins: integrity, damage, gravity, relations, audio,
juice, settings. The ship stack (input, sections, flight, camera, physics) is
NovaShipPlugin’s, and the HUD is NovaHudPlugin’s - both added by
nova_core, not by gameplay.
Gravity is one-way: planet roots are static wells, while mobile asteroids and
all ship roots are gravity-affected. Physical debris joins when its kinematic
collision grace ends. PlanetConfig::mass is required; asteroids have no
AsteroidConfig::mass. NovaLayeredWorld seeds a tangent only for a unique
well whose whole orbit fits its unfaded, collision-clear band and its owning
sector; inside an unsafe single-well band it seeds outward escape, and in free
space it seeds an explicit zero-velocity coast. A candidate inside overlapping
wells is repositioned deterministically within its cluster and owning cell,
first in a checked unique-well orbit band, then in well-free space. If no
candidate fits, SectorFault::Generation names the seed, sector and body;
streaming still panics on that fault. Seeded tangents do not guarantee lasting
orbits. Mobile children keep their spawn-sector root even across a
cell face; retirement removes them and revisiting regenerates their initial
position and velocity, resetting orbit phase and damage. Removing a well
clears its DominantWell references. If an ORBIT target disappears before the maneuver
reaches Hold, the autopilot disengages and the order fails; a completed held
ORBIT retires its execution and helm authority when its well disappears. A later
engage starts with fresh autopilot phase and plan state.
bevy_rand is added THERE and nowhere else, so the whole app holds exactly one
seeded GlobalRng (NOVA_SEED picks the seed, see
Environment variables). A gameplay system that needs
randomness takes it as Option<Single<&mut WyRand, With<GlobalRng>>> and builds
no fallback of its own: a second entropy plugin anywhere in the assembly does not
make two RNGs, it makes every one of those systems find none. Each of them logs
the broken contract once and then does its non-random work anyway - a destroyed
section still loses its collider, it just bursts no debris. nova_gameplay’s
plugin test pins the count at one.
States
GameStates { Loading, MainMenu, Playing }(nova_gameplay) - top-level lifecycle.MainMenuonly occurs whenNovaMenuPluginfronts the app (the default editor app); examples with custom game plugins go straightLoading -> Playing. TheGameModeresource (Sandboxdefault |NewGame) records what the menu handed off to.- Leaving
Playinggoes back throughLoading, not straight to the menu.nova_menuwritesReloadContentonOnExit(Playing)- the Wesnoth rule: the front door is where the game catches up with what is on disk, so a scenario just played, a ship just saved or a mod just installed is merged before the Scenarios picker, the campaign list or the ship catalog is read again.restart_through_loadingrewrites the pendingMainMenuintoLoadingin theStateTransitionschedule, beforeStateTransitionSystems::DependentTransitions, so the menu, its UI camera and its ambience backdrop are never built for the one frame the restart would have thrown them away in. Every exit path gets this - the pause overlay’s Back, the editor’s File menu, the outcome frame, a scenario’s ownExitToMenu- because the redirect is on the transition rather than on the four call sites, andOnExit(Playing)is also where the scenario is torn down (end_gameplay_scenario) rather than on whichever screen comes next. Menu ENTRY owes the same debt on its own account:load_menu_ambiencetriggersUnloadScenariobefore it draws anything, so the branch that finds no clean backdrop - a mod set with nothing flagged, or nothing the content gate lets through - puts its bare camera over an empty world instead of over whatever was last running. An app with no content pipeline (noGameAssets) is exempt: it has no restart to come back from. PauseStates { Unpaused, Paused, Interface, Commands }- the freeze axis.Pausedis the ESC pause overlay;Interfaceis the TAB pane (Map, Ship or Inventory,InterfacePaneType, a resource that outlives the state);Commandsis theNOVA COMMANDSCRT modal (cursor freed, no pause menu). The live transitions areUnpaused <-> Paused,Unpaused <-> Interface, andCommandsto and from each ofUnpaused,PausedandInterface: the modal is a surface OVER what was there, soNovaOsCloseTransition::return_torecords the state it covered and closing restores it.nova_gameplayowns the enum and gates the spaceship sets;nova_menuowns the toggles and the pause overlay UI. The two keys are gated differently. Tab needs a player ship and so runsin_state(Playing); Tab and M both refuse while a ship section rebind waits for its key, so the capture is not dropped.:is a GLOBAL gesture over the main menu, the editor and flight alike - gated only on no armed rebind (a settings chip or a ship section waiting for its key) and not mid-load, where there is nothing yet to inspect.Escapehas ONE owner for both surfaces (close_surface_from_menu_keys): an armed rebind keeps the key, then the modal closes, then the interface closes, then the pause menu may act, so one press never closes two layers. On the main menu the open shell is still the active modal, because its full-screen backdrop takes every pick and the menu buttons under it are unreachable until it closes.- The pause PANEL is reconciled, not spawned on the way in
(
reconcile_pause_overlay):Pausedis a freeze axis three surfaces share, and the outcome frame and the FAILED TO START report each hold it while drawing a modal of their own. The panel exists exactly whilePausedis the pause MENU’s, so an outcome landing behind it takes the screen, and an outcome clearing into a pause the player still owns hands it back. Exactly one modal, whichever arrived last. - Losing the window pauses interactive play (
pause_on_focus_loss): an unpaused run over a live scenario takes the ordinary pause the moment the window is not focused, and regaining focus never resumes - Resume is the player’s. A run already paused stays as it is, so an open interface, an open command modal or a shown outcome remains the active modal, and the editor’s build mode (no live scenario) never pauses at all.FocusPausecarries the policy and boots OFF under a harness (harness_env_active): a probe drives an X display nobody is looking at, and a--norenderrun has no window to lose. An authoredauto_advance_secskeeps running on the wall clock while unfocused, and the chapter it advances into gets no unpaused frame - the outcome’s pause is TRANSFERRED to the pause menu rather than released (examples/systems/bug_outcome_pause.rs). - The freeze itself is a NAMED hold, not a boolean:
ClockFreezecountsFreezeOwner::{PauseMenu, Interface, ScenarioLoad}and stopsTime<Virtual>+Time<Physics>while any owner holds. It has to be named because the surfaces stack -:opens the modal over an already-paused game - and the handover is a state transition:OnExit(Paused)releases thePauseMenuhold beforeOnEnter(Commands)takes theInterfaceone, and the way back re-takes it. Naming the owners is what stops one surface’s release from unfreezing the other’s world.InterfaceandCommandsshare the oneInterfaceowner, andrelease_clocks_for_interfaceskips the release when the transition enters the other of the two, so opening the modal over a pane and closing it again never ticks the world between a release and the re-hold it would need. The hold is SCOPED toPlaying(hold_clocks_for_interface): what runs behind the main menu is the ambience backdrop, a cinematic the modal is drawn over rather than a game the player is being kept out of, and stopping it would leave the front door on a still frame for as long as the modal is up (examples/systems/system_command_shell.rs). - Every full-screen surface takes its
GlobalZIndexfrom one table,nova_ui::layer: HUD, the menu’s own panels, the editor’s ceiling, the report frames (outcome, FAILED TO START, MODS DISABLED), the pause menu, its Settings panel, the command modal’s backdrop and monitor, the diagnostics exempt from its dim, the scenario loading screen, the fatal asset report. The TAB interface root sits on the menu-panel layer: above the flight HUD, below the modal. Two modals sharing a number are ordered by whatever the UI stack’s traversal produces that frame, which is not a decision - the pause Settings panel and the open command modal shared 11 until the table was written, and only survived it because the pause overlay isDespawnOnExit(Paused)and is gone before the modal draws. The editor keeps its own ladder (nova_editor::ui::layer) for chrome the two tables cannot see across; that ladder lives entirely underEDITOR_CEILING_Z, pinned by a test, so a pause or an outcome covers editor chrome instead of losing to its foot bar. - The window a native run opens is BORDERLESS by default and has a floor.
WindowModeSettingdefaults toBorderless, and the store’s field carries#[serde(default)], so a fresh install and an older store with no field both open full-screen while a player who explicitly savedWindowedkeeps it;apply_window_modeis registered only for a READING store, so a harnessed run (inert store) never touches the window it was given.window_pluginhands the window managerMIN_WINDOW_WIDTHxMIN_WINDOW_HEIGHT(640x600) as resize constraints. The floor is not what makes the layouts safe: the Settings panel (the widest modal, and the same one from the menu and from pause) sizes itself atSETTINGS_PANEL_WIDTH_PCTof the window capped atSETTINGS_PANEL_MAX_W, because a canvas in a narrow page column obeys no native constraint. A fixed width there did not overflow - the panel is a flex ITEM, so at 500 px the 620 px panel measured 500, squeezed edge to edge with the keybind rows’ label column paying for it (examples/systems/system_pause_settings.rs). GameAssetsStates { Boot, Loading, Processing, Loaded, Failed }(nova_assets) - asset pipeline.Bootloads the UI font the loading screen itself draws with;Loadingloads the rest of the MANDATORY set (the shared art and the catalog, whose only loaded dependency is the base game’s bundle);Processingkicks the OPTIONAL cataloged mods, waits for them to settle, and merges. Scenario setup hooksOnEnter(GameAssetsStates::Loaded)- seeexamples/systems/system_scenario_grammar.rs.Failedis the MANDATORY failure and is terminal:nova_core’s loading screen replaces its animation with a report that offers Quit on native and browser instructions on the web, and draws in the default font when the boot font is what failed. An OPTIONAL mod never reaches it -safe_modedisables that mod, persists the choice, and the front door owes oneMODS DISABLEDreport per recovery episode (examples/systems/bug_failed_assets.rs).
The top-level lifecycle, the pause overlay nested inside Playing, and the asset
pipeline that gates entry:
stateDiagram-v2
state "GameStates" as GS {
[*] --> Loading
Loading --> MainMenu: menu app
Loading --> Playing: custom game plugins
MainMenu --> Playing: New Game Create / Sandbox
Playing --> Loading: leaving gameplay (content restart)
state "Playing" as Playing {
[*] --> Unpaused
Unpaused --> Paused: ESC / focus lost / outcome
Paused --> Unpaused: ESC
Unpaused --> Interface: Tab
Interface --> Unpaused: Tab / ESC
Unpaused --> Commands: :
Commands --> Unpaused: ESC
Paused --> Commands: :
Commands --> Paused: ESC
Interface --> Commands: :
Commands --> Interface: ESC
}
}
state "GameAssetsStates" as AS {
[*] --> Boot
Boot --> AsLoading: Loading
AsLoading --> Processing
Processing --> Loaded
Boot --> Failed: mandatory asset failure
AsLoading --> Failed: mandatory asset failure
}
AS --> GS: OnEnter(Loaded) hands off to MainMenu / Playing
Leaving Playing resets PauseStates back to Unpaused.
Frame flow
Gameplay systems run in an explicit chain, configured identically in Update and
FixedUpdate. nova_ship::NovaShipPlugin declares the brackets and the ship
sets; nova_hud slots NovaHudSystems into the gap itself:
SpaceshipSystems::First -> SpaceshipInputSystems -> SpaceshipSectionSystems
-> NovaHudSystems -> NovaCameraSystems -> SpaceshipSystems::Last
- Physics (avian3d) runs in
FixedPostUpdateon a fixed timestep. Rigid bodies getTransformInterpolationso rendering stays smooth between physics ticks. PostUpdatehosts the chase camera’s final move and the HUD’s world-to-screen projection, ordered after it.- While
Paused, the input and section sets are gated off and the clocks freeze. PreUpdatehostsnova_ui’sInputModeSystems, which resolves that frame’sClaimKeyboardmessages into the oneInputModeevery keyboard system then gates on. A claimant writes its claim.before(InputModeSystems); a consumer reads the resolved mode within_input_mode,in_input_mode_at_mostorowns_or_enters.
The render-rate chain (run in both Update and FixedUpdate) versus the
fixed-timestep physics step and the interpolation that smooths rendering between
ticks:
flowchart LR
subgraph render["Update / FixedUpdate chain"]
first["Spaceship First"] --> input["Spaceship Input"]
input --> section["Spaceship Section"]
section --> hud["Nova HUD"]
hud --> cam["Nova Camera"]
cam --> last["Spaceship Last"]
end
subgraph fixed["FixedPostUpdate"]
phys["avian3d physics (fixed step)"]
phys --> interp["TransformInterpolation"]
end
subgraph post["PostUpdate"]
chase["chase camera final move"] --> worldscreen["HUD world-to-screen"]
end
render --> fixed --> post
Update vs FixedUpdate - which schedule does my system go in?
The chain above is configured IDENTICALLY in Update and FixedUpdate
(nova_ship::NovaShipPlugin, two configure_sets calls with the same set order),
so a gameplay set can host systems in either schedule. The split is not
cosmetic: since every dynamic body opted into avian’s TransformInterpolation,
the game carries two pose representations on two clocks:
- Raw physics pose – avian
Position/Rotation, advanced on the 64 HzFixedUpdatetick. This is the truth the simulation integrates. - Render pose –
Transform, eased between the previous and current physics states, withGlobalTransformpropagated from it inPostUpdate.
Which schedule:
- Put a system in FixedUpdate when it feeds the physics sim – forces and
impulses, spawns whose motion the fixed clock advances (torpedoes, which
physics integrates; gun rounds, which
nova_gameplay::roundssweeps by hand after the physics step), guidance. It MUST read the rawPosition/Rotation(or compose the root’s raw pose with a local mount offset). DuringFixedUpdateof frame N,GlobalTransformstill holds the eased pose propagated in frame N-1’sPostUpdate, so it is stale render state here; the avian child-collider pose is one tick stale too. - Put a system in Update (or
PostUpdate) when it consumes the rendered frame – camera, HUD world-to-screen projection, effects. It reads the easedTransform/GlobalTransform, and every pose in one on-screen computation must come from the same frame. A consumer ofPostUpdate-written state must be ordered after its producer. - Put a system in FixedUpdate when what it computes DECIDES a fixed-step consequence, even when the same value is also drawn. This is the rule that is easiest to get wrong, because such a system looks like render-rate work: it has an on-screen output, so it reads as belonging beside the camera and the HUD. Ask instead whether anything on the fixed clock BRANCHES on what it writes. If it does, the state that branch samples advances once per FRAME while the branch is taken once per STEP, and the outcome is a function of the host’s frame rate.
Why gameplay is split across both: the chain runs in Update for
render-rate work and in FixedUpdate for sim-rate work; the same set order in
both keeps ordering consistent wherever a system lands.
The fixed loop runs on the SINGLE-THREADED executor (AppBuilder::assemble), so
a FixedUpdate system gets no parallelism from its neighbours - only from its
own par_iter. That is a deliberate trade for the schedules’ size; the number
behind it is in Measuring performance.
The same method caps the compute task pool at nova_core::MAX_COMPUTE_WORKERS
threads, so the parallelism a par_iter or a PostUpdate system can reach is
bounded too (why).
What breaks if a system lands in the wrong schedule – worked example: a
FixedUpdate system reading GlobalTransform. thruster_impulse_system used to
apply its impulse at the thruster child’s GlobalTransform, i.e. the previous
frame’s eased pose, up to ~2 ticks of ship motion behind the raw physics it was
pushing, while taking thrust DIRECTION from the raw Rotation – mixing both
clocks in one impulse. The application-point error is proportional to velocity;
at speed a COM-centered engine developed an uncompensated lever arm the throttle
balancer could not see, and the measured failure was a zero-true-torque lateral
engine spinning the hull to 7.1 rad/s in 15 frames (0 rad/s after reading the raw
pose). The fix (thruster_section.rs, see the comment above apply_linear_impulse_at_point)
composes both application point and thrust direction from the root’s raw
Position/Rotation. The same footgun produced the bullet-spew, HUD-jitter and
crosshair-twitch bugs in that family; all were error proportional to velocity,
which is why they only showed at high speed.
Worked example for the third rule – a DRAWN value that gates a fixed-step
branch. A turret’s aim chain solved the intercept, drove the hinges and wrote
the joint pose in PostUpdate, which is where a thing you can see belongs. But
shoot_spawn_projectile runs in FixedUpdate and asks, per tick, whether the
muzzle is inside a 0.92 deg cone of that aim point. The barrel’s pose was
therefore a staircase with one step per FRAME, its tracking residual scaled with
the frame period, and the cone is narrower than the residual’s span: measured on
stress_point_defense, the battery held its trigger 9.7% of the time at 20 fps
and 60.6% at 106 fps – same scene, same seed, a six-fold difference in how
strong point defence was. Moving the whole chain (solve, hinge controller, joint
sync) onto the fixed clock made the trigger duty 0.811-0.817 across the same
span. Nothing about the aim chain looked like sim work; the tell was that a
FixedUpdate system branched on its output.
Cross-system communication goes through events and observers (Bevy On<...>
observers, e.g. the integrity/destruction chain) rather than direct calls. Prefer
adding an event/observer over coupling two systems.
Assets
assets/ is runtime-only - everything the game actually loads: shaders/
(.wgsl), icons/, sounds/ (UI chrome: menu clicks + objective chimes),
and the base//mods/ data (.ron). The base game’s own art and world audio
live UNDER assets/base/ (exported gltf/ models .glb, textures/,
sounds/ .wav world cues, banner.png), referenced by base content with
self:// and by mods with dep://base/<path>. It is the whole directory the
web (Trunk copy-dir) and native (release.yaml) builds ship, so non-runtime
files must not live here. The Blender SOURCES the gltf/ models are exported
from live OUT of the shipped tree, in top-level art/blender/, because nothing
loads them at runtime. The built-in sections, ships, styles, scenarios and training
lessons ARE data: the Rust builders under
crates/nova_authoring/src/base_content/ (sections/, ships/, styles.rs,
scenarios/, lessons.rs, assets.rs) are the single source, and
cargo run content gen serializes them to the committed
assets/base/**/*.content.ron the game loads like any other bundle.
Never hand-edit the generated files; edit the builders and re-run gen.
Find it in the code
- App assembly, plugin order:
AppBuilder-crates/nova_core/src/lib.rs; game binary and CLI flags -src/main.rs. - States:
GameStates,PauseStates,GameMode-crates/nova_gameplay/src/lib.rs; ESC overlay and clock freeze -crates/nova_menu/src/pause.rs. - The command modal and the TAB interface: the terminal model -
crates/nova_command/src/terminal/state.rs; the command catalog, parser andCommandChannel-crates/nova_command/src/commands.rs; the dispatcher that runs them against the world -crates/nova_console/src/dispatch.rs; the:gesture -open_command_shellincrates/nova_menu/src/pause.rs; the interface’s open, close and pane switch -crates/nova_interface/src/pane.rs; the shared back-out owner -crates/nova_interface/src/terminal/input.rs. - Frame-flow sets:
SpaceshipSystems-crates/nova_gameplay/src/plugin.rs; chained inUpdate+FixedUpdatebyNovaShipPlugin-crates/nova_ship/src/lib.rs. - Who owns the keyboard:
InputMode,ClaimKeyboardandInputModeSystems-crates/nova_ui/src/input_mode.rs; the editor’s claimant -declare_editor_keyboard_ownerincrates/nova_editor/src/lib.rs. - The two damage readings:
DamageLevel-crates/nova_gameplay/src/integrity/erosion.rs;DamageMarksand the carve cost model -crates/nova_gameplay/src/integrity/carve.rs. - Asset gate and mod merge:
GameAssetsPlugin-crates/nova_assets/src/plugin.rs;register_bundles-crates/nova_assets/src/merge.rs. - The training handbook: the lesson format and catalog ordering -
crates/nova_training/src/catalog.rs; the validation bothcontent lintand the merge run -crates/nova_training/src/validate.rs; the screen -crates/nova_menu/src/training.rs; the progress record and what a won scenario proves -crates/nova_menu/src/training_store.rs; the base lessons, their practice ranges and what each one isproven_by-crates/nova_authoring/src/base_content/lessons.rsand.../scenarios/drills.rs. A lesson’swiki_pathpoints into the web manual underweb/src/, andcrates/nova_authoring/tests/lesson_wiki_links.rsfails if a page or heading it names stops existing. - API detail:
cargo doc --open -p nova_core(any crate from the map works).