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

Development

Toolchain

  • Rust nightly, pinned by rust-toolchain.toml (with rustfmt + clippy).
  • NixOS: nix develop gives the toolchain, the wasm32-unknown-unknown target, all system libs Bevy needs (udev, alsa, vulkan, X11/wayland), trunk, and sccache (see fast worktree builds below). Without Nix, install those yourself. Bare cargo is not on PATH under Nix: run every cargo/rust command via nix develop --command <cmd> (the commands below assume you are inside nix develop).
  • Playing it, as opposed to working on it: nix run builds and launches the packaged game with no checkout and no development shell. See The nix package.

Everyday commands

cargo run                         # the game (boots into the main menu)
cargo run -- --scenario tutorial    # the game, straight into one scenario
cargo run --features dev          # + debug tooling (inspector, wireframe)
cargo run --example system_scenario_grammar   # run an example
cargo build --release             # release profile: opt=s, lto, stripped
cargo check && cargo fmt          # before committing
cargo test --workspace            # full suite (CI runs this; skip locally unless asked)
cargo run content lint   # validate content: refs + ship parts + balance + input overlaps (also: gen)
cargo run --features debug probe run system_player_path          # run-harness check (correctness + perf)

Notes that keep the suite honest and fast:

  • Use cargo test --workspace, never bare cargo test: unit tests live in the member crates, so the bare form runs almost nothing and gives false comfort.
  • cargo test takes ONE filter and one -p per invocation; separate runs for separate filters or packages.
  • For a timed headless example run, build first, then time only the run (cargo build --example X --features debug, then NOVA_AUTOPILOT=1 timeout N cargo run --example X ...). A cold build inside the timeout burns the window.
  • Struct-field changes: cargo check --workspace --all-targets, or examples and tests stay silently broken.

The dev profile uses opt-level = 1 for our code, 3 for dependencies: slow first build, fast iteration. split-debuginfo = "unpacked" + debug = "line-tables-only" keep link-time RAM around 20 GB instead of 40 (one Bevy-sized binary per test/example target); set debug = true temporarily if you need a debugger.

Worktree builds (fast via sccache): a fresh sprout worktree starts with an empty target/, but the devshell wires sccache as RUSTC_WRAPPER (with CARGO_INCREMENTAL=0, which sccache requires) so it does NOT pay a full cold build. sccache caches each rustc invocation’s output keyed by a hash of the source content plus flags plus compiler version, in a shared cache (~/.cache/sccache). Unchanged deps (bevy, avian, the whole pinned tree) are 100% cache hits across worktrees; only changed nova_* crates recompile.

Measured on 2026-07-21 (game binary, quiet host):

buildwall clocksccache stats
cold (empty cache)~6m45s (405s)517 misses / 0 hits
warm (cargo clean, same source)~38s517 hits / 0 misses (100%)

The warm number is what a fresh sprout worktree gets once the shared cache is warm. Recipe from a new worktree:

cd "$(sprout new <branch>)"
nix develop --command cargo build          # warm-cache: seconds, not minutes
nix develop --command sccache --show-stats # confirm the hit rate

Still do NOT point CARGO_TARGET_DIR at another checkout’s cache: cargo keys fingerprints on crate name + version + features + profile + rustc, NOT the source path, so two checkouts alias each other’s artifacts in a shared dir and a worktree binary can silently link another checkout’s code (the stale-binary incident). Each worktree keeps its OWN target/; sccache is the SAFE way to share compilation because its cache key IS the source content - there is no path where a worktree links code from different source. That content-keying is also why sccache is transparent to CI: an empty cache is just a cold build.

The devshell sets CARGO_INCREMENTAL=0 shell-wide (sccache is incompatible with incremental). This costs the main checkout’s iterative edit-rebuild loop its incremental speedup; the fresh-worktree-per-task agent workflow only ever does cold-shaped builds, so it is pure win there. A sprout-scoped variant (export the wrapper only in sprout shells, keep the main checkout on incremental) is possible as a nix.dotfiles follow-up if the main-checkout iteration cost bites.

Features

  • debug - the whole nova_debug plugin (inspector, wireframe, overlays) plus bevy/track_location.
  • dev - alias for debug.
  • trace - bevy/trace + bevy/trace_chrome for span traces; the probe harness builds --features debug,trace when it needs one.

Debug tooling

cargo run --features dev compiles in nova_debug’s DebugPlugin (crates/nova_debug/src/lib.rs), which adds the inspector, the wireframe toggle, and the section/gravity debug overlays. The overlays are gated on a DebugEnabled resource toggled at runtime with F11 (DEBUG_TOGGLE_KEYCODE), so they can be flipped off without a rebuild. Note the feature is spelled debug, with dev as an alias for it (root Cargo.toml); --features dev and --features debug are interchangeable.

DebugPlugin also binds F12 (SCREENSHOT_KEYCODE, crates/nova_debug/src/screenshot.rs) to a screenshot: it captures the primary window and saves it to your Downloads directory as <unix-millis>.png. The capture is intentionally not gated on DebugEnabled, so it works whether or not the overlays are shown. Native only: the module is compiled out under target_arch = "wasm32", which has neither a Downloads directory nor a wall clock.

Three debug-only CLI flags exist, all parsed in src/main.rs and all compiled in only under the debug feature. Two of them are the OUTPUTS-OFF pair - --norender drops the renderer, --mute silences the speakers - and each has an environment twin an example can be armed with, since an example has no command line of its own:

  • --norender - build the app through AppBuilder::headless(): no wgpu device, no window, no winit event loop, and none of the visual game plugins. The main schedule still ticks, so the simulation runs and a probe capture still counts frames - CPU ones, with render_world and gpu reading zero.

    Nothing in a headless app ends the run. There is no window to close and no input of any kind, so it needs a driver: --scenario <id> under NOVA_AUTOPILOT, or probe scenario. A bare --norender ticks until it is killed, and that is by design rather than a hang to report.

    An EXAMPLE has no command line of its own, so the flag cannot reach it. The environment does: NOVA_NORENDER=1 makes every AppBuilder::new() in the process assemble the same headless app, which is how a range runs without a GPU. Set to anything, including empty; unset means render. AppBuilder::headless() ignores it, being already headless.

    NOVA_AUTOPILOT=1 NOVA_NORENDER=1 cargo run --features debug --example stress_point_defense
    cargo run --features debug probe run stress_point_defense --norender
    

    Headless is a speed option, not a substitute for a rendered run. With no device there is nothing to break: a duplicate-component panic, a material or pipeline failure, and the async-compile SIGSEGV synchronous_pipeline_compilation exists to prevent are all invisible headless, and cargo check does not see them either. Only a rendered run does. Run ranges headless for speed if you like; keep a rendered set as the canary.

  • --mute - zero the audio output. The other half of the outputs-off pair: Xvfb hides the window but not the speakers, and nobody listens to a scripted run. It inserts HarnessMute(true) after the builder, so it wins over whatever the environment resolved. The volume SETTING is untouched, so persistence and the settings menu never see it, and a muted run says nova audio: output muted for this run once at startup.

    The environment twin is NOVA_MUTE, which an example reads through HarnessMute::from_env: set to anything but 0 it mutes, NOVA_MUTE=0 forces sound even under a harness, and unset it mutes iff a harness variable (NOVA_AUTOPILOT, NOVA_CAPTURE) is set.

    cargo run --features debug -- --mute --scenario asteroid_field
    NOVA_MUTE=1 cargo run --features debug --example stress_bullets
    
  • --debugdump - print the system schedule graph (via bevy_mod_debugdump) and exit. It dumps the Update schedule (debugdump in crates/nova_debug/src/lib.rs).

Logging

The filter is built by log_filter_str in crates/nova_core/src/lib.rs. A --features debug run puts the nova crates at DEBUG; a release run leaves them at the plugin’s INFO default.

A new crate needs no filter change. Every workspace crate is named nova_* and EnvFilter matches a directive’s target by PREFIX, so the single nova= directive covers all of them. Do not add a per-crate directive: the list this replaced named nine of twenty-two crates, and the thirteen it missed sat silently at INFO while their neighbours were at DEBUG.

Pick a level by WHO needs the line and HOW OFTEN it fires, not by how interesting it felt while writing it:

levelfiresexample
trace!per ITEM - anything that scales with contentone line per spawned object, per section, per widget
debug!per OPERATION - one line for the whole batchscattered 26 of 26 'gauntlet_rock_' object(s)
info!a person running the game wants it; should be rarethe probe’s own report lines
warn!/error!something is WRONGa scenario names an id that does not resolve

An expected-and-handled condition is not a warning. A batch summary carries a COUNT - a summary line without a number is barely better than the noise it replaced.

RUST_LOG directives are MERGED over this filter by bevy rather than replacing it (bevy_log-0.19.0/src/lib.rs:406), so RUST_LOG=nova=trace restores the per-item detail without unmuting wgpu and naga.

A headless run additionally clamps three bevy diagnostics it provokes by construction - the missing render app, CompressedImageFormatSupport, and gizmos noticing there is no RenderApp. All three are unreachable when a render sub-app exists, so a rendering run keeps those targets at their normal level.

Examples

examples/ exercises one subsystem each, end to end; this repo prefers runnable examples over isolated unit tests. The examples live in purpose directories (bevy-repo style: category dirs, plain slug names), and the [[example]] catalog in the root Cargo.toml (autoexamples = false) is the single source of truth, listed in curriculum reading order.

The category contract

A category is not a folder - it is a promise about WHO an example is for. Pick the category by its audience, not by what it happens to spawn.

CategoryWho it is forWhat probe does with itDisqualifies an example
playable/A HUMAN: somebody loads it and does the thing it demonstrates, through an affordance wired outside the NOVA_AUTOPILOT gateruntime contract decides; native trace is automaticits only affordance is the free-fly camera every cameraless scene already gets
systems/The PROBE: a behavior staged and asserted, every claim named on the invariant rosterruntime contract decides; native trace is automaticits product is a frame for human eyes rather than a verdict
screenshots/The WEBSITE and the wiki: frames, webm loops, posed lineups, the frame-time baselineruntime contract decides; native trace is automaticits verdict is an assert, or a human is meant to drive it

The test between playable/ and the other two: would a human loading this expect to DO something? If the name promises a verb, it owes the verb. The test between systems/ and screenshots/ is what a run PRODUCES - an assertion, or a picture.

There are THREE categories and there is no fourth: an example that does not fit one of them is miscategorised, not a new kind. Autopilot in a playable/ example is a SECOND driver, for captures and for the run gate - never the only one.

Run policy is DECLARED by the example at runtime, through the probe plugins it wires (nova_probe::contract); probe reads it back from probe-contract.json. Every cataloged example is spawned (--all subtracts nothing), and one that declares no capability grades UNPROBEABLE - the sanctioned opt-out, gated on its smoke checks alone. The grading detail lives under Run verification (probe); what each category proves is the table above plus the per-block comments in the root Cargo.toml, and review enforces it.

The catalog and the harness

What is on disk today, in reading order:

  • playable/ - what a person loads and works. The hulls first: carve_asteroids (fly a PDC rig at a row of shipped-size rocks and hole one by hand) and wfc_arena (a match bench for wave-function-collapse hulls, with a lobby, a pause menu, a result board and a --ship TEAM:player slot that puts you in one of them). Then the benches and galleries, each with its own keys: wfc_ships (R re-rolls the collapsed row), shape_bench and block_bench (L cycles the style, C strips the cladding), railgun_wake_bench (the slug’s ionized wake at three speeds, 1-3 pick the lifetime policy, the arrows tune it, [ ] slow it down), greeble_catalog (a selection ring, a focus turntable, pedestal and cell-frame toggles), parts_viewer (a paged grid, a focus turntable and a reassembled recipe ship with an explode toggle), widget_zoo (every nova_ui widget factory, live and clickable in both skins), compare_asteroids / compare_planets (the number keys re-dress the focus subject) and asteroid_kinds / planet_types (the whole authored range of each, side by side). Last, the mission-scale pair: first_shift_map is a fixed belt layout with a route over it - the most populated authored scene there is - and first_shift_ships poses eight shipped hulls in a row. All of them still walk and capture under NOVA_AUTOPILOT, which is what keeps them on the probe gate.
  • systems/ - the correctness ranges. Every name carries a prefix for the KIND of check it is - system_ functionality, bug_ a regression range for a defect that was found, stress_ load - and examples/systems/README.md owns the rule. The section curriculum first: system_attitude_hold (PD attitude), system_thrust_and_plume (burn -> thrust + plume shader), system_hull_damage (damage -> destroy -> ship survives, and the mass properties the losses move), system_section_severing (a destroyed interior section leaves a real hole and the structure behind it drifts free as its own wreck body, at the speed its own size asks for), system_destruction_finale (every destructible body - gltf section, procedural section, multi-part turret, asteroid - breaking into its OWN art rather than generic cubes, on one budget), system_turret_gunnery and system_torpedo_launch (the weapon ranges, the latter also the PN lead-a-crosser deep-dive), and system_blast_penetration (explosive falloff, one section shielding the next and two salvos that cannot use the hole they make in the same tick) and system_railgun_lance (the spinal gun: a commit that outlives the trigger, a charge cue, one slug raking six plates in a single step, and the recoil that shoves the firing ship). Beside them, the ranges that read a figure off the HULL rather than off a constant, each staged on a skiff and a capital at once: system_hull_scaling (the two reference hulls’ live inputs and what the game derives from them - lock range, the fire gate, the size a hull burns at), system_collision_damage (a ram at three closing speeds, and the docking speed that costs nothing), system_ai_combat (what an AI ship does when it fights) and system_ai_evade (what it does when a gun is held on it: three jink legs, each the size of the hull flying it). Then the cross-cutting systems, every fixture a ScenarioConfig written in Rust and loaded with LoadScenario: system_scenario_grammar (the scenario language - variables, events, filters, actions), system_player_path (a scenario played through the real input pipeline: lock, kill, travel-lock, GOTO), system_cinematic (a SCENE: the prompt and title card it puts on the HUD, the pause that freezes the way out, the skip the player presses, and the two endings the scenario hears in order), system_outcomes (die -> the Defeat overlay -> Retry -> a clean reload -> kill -> the objective and the CHECKPOINT -> Continue -> the chained scenario), bug_outcome_pause (who owns the screen and whether the world is running behind them: alt-tab pauses a fight and coming back does not resume, an outcome landing over the pause menu takes the screen, and a timed advance taken while the player is away hands its pause to the pause menu without one unpaused frame), bug_refused_scenario (a chapter the content gate refuses ends the one it replaces, cursor freed and Main Menu working), bug_failed_assets (a broken optional mod is quarantined and reported, and a broken base asset is fatal and says so), bug_neutralized_quiet (a wreck’s point defence stands down) and system_borrowed_battery (the Flight Computer works idle player PDCs). Then the interface, driven by synthesized pointer input: system_ship_editor (build a ship and inspect it, refusals included), system_field_controls (the inspector’s number rows: the unit a field’s declaration gives it, the step a drag on its name moves it by, and the floor that drag ARRIVES at where a typed value is refused), system_input_modes (one owner of the keyboard at a time - the same key pressed under a text field, the gallery, a pending rebind and none of them, each verdict a NEGATIVE read after a settle because a key that went nowhere raises no event), system_ui_scale (a world-anchored label holds its LOGICAL offset when the scale factor doubles and the window changes shape, and the top bar and the stage’s nameplates are read at every shape alongside it), system_hud_indicators (where a screen-projected indicator lands), system_menu_boot (the shipped boot flow), bug_menu_fallback (a menu with no clean backdrop to draw ends the scenario it came from and stands on one bare camera) and system_scenario_picker (the Scenarios picker: its pane split must not depend on the selection - real fonts, real taffy, which a headless unit rig cannot measure at all - and the row a pointer selects and plays is the scenario that comes up once the atomic load gate releases), bug_sandbox_soak (the editor sandbox entered and then left alone, holding one physics step to its own timestep), bug_carve_apply (one cut that severs a rock, and what the main thread pays to SWAP the result in - the range counts grids rather than milliseconds, so it reads the same on every box), system_interface (the Tab interface and the : modal over it, driven with keys only: TAB, M and : must return through Escape to the same pane with the clocks frozen and no pane or flight action firing on the transition frames), system_command_shell (the modal over flight, the pause menu, the main menu and the interface, including the rebind gate that keeps : shut while a ship section waits for its key) and system_headless_map_goto (a click on a Map blip in window space, then GOTO, asserted on the ship’s autopilot with no GPU). Finally the STRESS ranges, one file each: stress_bullets, stress_torpedoes, stress_point_defense, stress_one_structure and stress_many_structures hold a thousand rounds, a thousand guided torpedoes, one battery working a stream of inbound ordnance, a thousand sections on one body and a hundred bodies, each asserting exact counts, a drain to zero and a teardown that leaves nothing, with a frame-time capture riding along. Their scale constants are named and carry the comment that they must NEVER reflect real content; stress_point_defense also reads NOVA_STRESS_PD_MOUNTS and NOVA_STRESS_PD_BAYS, so one build can be swept across scales without moving what it asserts. stress_hull_collapse is the odd one out: it fires ONE siege slug down the long axis of a 1296-cell hull and asserts the corridor that left it and that every piece it shed went physical, then RECORDS the collapse frame cost and the debris peak instead of capturing frames, because a one-shot walk cannot fill the baseline window. The UI idiom: a beat NAMES its target (click_named / hover_named / ui_node_centre / ui_node_rect in nova_autopilot::input) so a layout move is survivable and only a rename breaks a run; nothing reaches a widget by triggering its observer or inserting its state component. A target past the fold is SCROLLED to (scroll_lines / scroll_pixels turn the wheel), so a list taller than its box costs the run nothing, and a target behind a render target is AIMED at by undoing the composite that displays it (nova_os_window_px_showing), since a node laid out by an offscreen camera reports a rect in a space no cursor can be placed in. A driven run that still cannot reach a target says so and states its COVERAGE in the verdict - system_scenario_picker names any row it gave up on and fails outright below two measurements, since its property is a comparison across selections. The simulation ranges deliberately do the opposite - their subject is the outcome chain, so pixel coordinates would only add layout coupling.
  • screenshots/ - the content producers, and the NAME says what a run makes: screenshot_* writes STILLS, loop_* writes VIDEO. One producer captures ONE thing in at most THREE frames, because a long scripted walk cannot hold the same result twice - the fat sets these replace strung a dozen captures onto one script, so a beat that drifted took every frame after it with it. Duplication between producers is the accepted price: several stage the same set, and the scene builders they share live in examples/screenshots/shared/. The stills, by set: the Drydock drift beauty shot (screenshot_hero_ship), the menu and editor walks (screenshot_menu, screenshot_scenario_picker, screenshot_editor), the section closeups (screenshot_section_frame, screenshot_section_weapons), the Tab interface and the command shell (screenshot_interface, screenshot_command_shell), the Rock hollow combat beats (screenshot_radar_lock, screenshot_contextual_hud, screenshot_combat_lock, screenshot_combat_hud, screenshot_combat_wide, screenshot_hull_juice, screenshot_torpedo_run, screenshot_comms), the spinal lance (screenshot_railgun) and the flight computer around a real well (screenshot_orbit, screenshot_goto_burn, screenshot_flip_burn). The loop_* producers write only video; recording is not their privilege, though - a loop_start/loop_end pair records from any harnessed example, so screenshot_railgun, screenshot_editor, wfc_arena, system_torpedo_launch, railgun_wake_bench, stress_hull_collapse and system_lock_line_of_sight each ship a loop beside their stills or their readings. What a run makes is decided by scripts/capture-web-media.sh (loops) and scripts/gen-web-screenshots.py (stills), which name the producer behind every file they package - with one deliberate gap. The three before/after splits are composed BY HAND from a capsule scene of the old release and a loop of the new one, so they enter the media script as IMPORTS: it names the split and the producer of its new half, but never runs that producer and never names the news-0130-{sections,belt,death}-after.webm file it writes. loop_sections_compare, loop_belt_compare and loop_death_compare are therefore run by hand, and the module doc on each says how. The posed LINEUPS live here too - screenshot_thruster_gallery (the shipped drive, the proposed shell family and the CC0 candidates in one named row) and screenshot_damage_levels (the same ship at five damage levels, side by side, which is one comparison and so one producer). Neither registers a key, so a hand-run is a free-fly look at a still row; either would move to playable/ the day it grows greeble_catalog’s selection layer. scripts/gen-web-screenshots.py --producers prints the list the site actually consumes, so a capture flow never runs off a hand-kept array. Every run of that script first checks the producer columns against the workspace’s [[example]] names and refuses to do anything if one no longer answers, and scripts/capture-web-shots.sh stops on an empty list rather than packaging the shipped bytes back over themselves.

When adding a substantial feature, add or extend the range that drives it. When fixing a bug, WRITE the range that reproduces it first: that is the doctrine in AGENTS.md, and the invariant roster is what keeps it honest.

Every example is HARNESSED: it drives itself under NOVA_AUTOPILOT=1, and probe can run any cataloged example - cargo run --features debug probe run systems (or screenshots, or playable) runs one category alone, and --all runs the whole catalog. probe-full.yaml runs a curated 24-example set in five subject shards on a release and on manual dispatch; a pull request runs a seven-example smoke subset. Neither CI runtime suite runs the whole catalog. Each example must reach Playing and exit without panic; every systems/ range additionally carries panic-on-failure behavior assertions with completion backstops (a stalled script fails instead of passing vacuously). The rosters are pinned by the display-free systems_ranges_assert_their_invariant_roster, so an invariant cannot be deleted into a still-green run. The screenshots/ and playable/ examples carry no behavior assertions of their own - they drive the shipped scenes to capture frames - but every one walks an AutopilotPlugin step timeline, so a beat that never resolves is an error exit naming that step, and every one adds nova_probe::NovaProbePlugin, so a probe run grades the walk on the engine invariants. Disk and catalog cannot drift: the display-free catalog_matches_disk test (crates/nova_probe_cli/tests/catalog_drift.rs) fails cargo test --workspace when a new example misses its [[example]] block. That is the case nothing else catches - with auto-discovery off, an uncataloged example file does not build at all and no other tool says so.

The drivers themselves - AutopilotPlugin, the screenshot capture, the completion protocol, and the full NOVA_* environment contract - live in the nova_autopilot crate and are documented on The automation harness. This page only shows the run recipes; that page is the contract.

Harness runs are SILENT and fly on the DEFAULTS: any harness env (NOVA_AUTOPILOT, NOVA_CAPTURE) zeroes the audio output via HarnessMute - Xvfb hides the window but not the speakers, and nobody listens to a scripted run

  • and makes SettingsStorePlugin inert, so the run loads no settings.ron, saves none, and applies no window mode. Both read the same pair through nova_gameplay::harness_env_active. The volume SETTING is untouched (the mute is an output mask; persistence and the settings menu never see it). NOVA_MUTE=0 forces sound through a harness run; NOVA_MUTE=1 mutes a normal one, and the game binary’s --mute flag does the same - see the outputs-off pair above.

A HAND-RUN example is not silent and not pinned: it reads the store like the game does, so with no settings.ron saved it opens in the default window mode, which is now borderless fullscreen. Save Windowed once from the game’s Graphics tab (or window windowed in the command shell) and every example follows - examples read that file, they never write it.

Examples as bug pins

A bug becomes a RANGE (AGENTS.md): reproduce it in examples/systems/ before the fix, and the fix is what turns the range green. A unit/App test still pins a system-level mechanism, but anything that only manifests in a composed scene belongs in a range (for example, system_menu_boot runs the shipped boot flow with the ECS fallback error handler swapped to panic, so unhandled command errors on those transitions fail CI). A range’s pin is an autopilot-script assertion (a named step whose on_enter asserts, reached only once the steps before it have waited on the world - see system_hull_damage/system_hud_indicators for the style) carrying an outcome: <slug> marker on the roster; the release’s curated probe suite runs 24 selected ranges, and a pull request runs seven of those. Other ranges compile in CI but need an explicit probe run for runtime proof. Caveat: the handler swap does NOT catch remove/despawn command warns (they bake in the WARN handler at queue time).

Launching a scenario from the command line

--scenario <id> on the game binary boots straight into that scenario, past the main menu - for anyone who would rather not click through the picker, and for a mod author testing one scenario id in one command:

cargo run -- --scenario tutorial           # a base scenario
cargo run -- --scenario my_mod_intro       # anything an ENABLED mod registers
cargo run -- --scenario nope               # refuses, and lists every id
  • The id is matched against the MERGED registry (base plus every enabled mod), so a mod’s scenarios are launchable by id, and so are the menu backdrops - a superset of the rows the Scenarios picker shows. The editor’s own Play range is not in it: it is the open document, not installed content, and the only way to it is Play in the editor.
  • An unknown id prints the full id list to stderr and exits non-zero. The check happens once the content merge has run, which is after the window opens: the merged registry does not exist before that.
  • The launch is the picker’s own door (NewGameScenario + GameMode::NewGame
    • GameStates::Playing), so the scenario comes up through the same loader and the same non-blocking load screen as a click on Play.
  • It is a launch REQUEST, spent on the first boot. Back to Main Menu re-reads the content on the way out, and the second pass through Loaded finds no request left to open - so the flag opens its scenario once and then hands the session over to the menu, instead of being a door onto itself.
  • Native only. The wasm bundle has no command line.

Content CLI

content (nova_authoring::cli::main, crates/nova_authoring/src/cli.rs; a subcommand of the game binary, not a separate bin) authors and validates the game’s content. Two subcommands, run from the repo root:

cargo run content gen                                   # regenerate the base and story-mod *.content.ron
cargo run content lint                                  # lint the whole content tree
cargo run content lint --target <mod>                   # lint one mod (dir, id, or `base`)
cargo run content lint --target <mod> --report r.md     # + write a per-mod report (md|html)
  • gen serializes the code-built base content into the committed assets/base/**/*.content.ron. The base RON is GENERATED from Rust builders (nova_authoring::generation, backed by private base_content) - edit the builder and regenerate, never hand-edit the RON, or the content_ron_parity test goes red.
  • lint runs EVERY content check in one pass (the audit subcommand was folded in here - balance is a kind of lint):
    • the identifier + geometry + resource checks the load/publish gates cannot (dangling NextScenario targets, unspawnable filter targets, duplicate ids, scenarios with no terminal Outcome, resource-ref membership, …);
    • the generated-ship part check - each bundle’s section catalog, with base and its transitive dependencies, must build one nova_world_base::ShipPartSnapshot: positive finite part stats, a docking capture angle under 90 degrees, a section id overlaid only along a dependency chain, a usable hull, controller, thruster, cargo intake and docking port, a weapon or thruster that fires straight out of one of its own faces, and no socket on the face a weapon, thruster, cargo intake or docking port fires, opens or docks through. Each fault is reported once per owner: the bundle that authors it, or else each bundle whose catalog raises it;
    • the combat balance/fairness audit - every combat scenario’s derived sheet, graded for spawned-dead (ERROR) and close-spawn (WARN) hostiles; a bundle acknowledges its OWN deliberate imbalances in a balance_acks.ron beside its manifest, so the justification travels with the mod (a stale ack that matches no live finding is an ERROR, so every list stays pruned);
    • the flight-rig input-overlap check - a content input_mapping section bound to a key the always-on flight rig also binds (W/Space/RightTrigger burn, autopilot, …) silently double-drives flight and is flagged (WARN).
    • --target lints a single mod by directory or in-repo id (webmods/<id>, assets/mods/<id>, or base); --report <path> writes a per-mod document (Markdown, or HTML for a .html path / --format html) that names, for each finding, the file + element + explanation + suggested fix. Exits non-zero on any ERROR. The content_lint_gate, balance_audit_gate and content_report_gate tests run these walks in CI.

Web build

WASM via Trunk (Trunk.toml, index.html):

trunk serve            # serve the game alone on http://localhost:8080
trunk build --release

For the full site (game at /play/, mod portal at /mods/) with everything watched, use scripts/serve-web.sh - see Local web preview below.

.cargo/config.toml sets --cfg=web_sys_unstable_apis for wasm; bevy_rand uses its wasm_js feature there. Trunk only supports the release profile. The GitHub Pages deploy (.github/workflows/deploy-page.yaml) builds the landing site (web/) at the root, the game under /play/, and the generated mod portal (scripts/gen-portal.py) under /mods/.

The same sources fan out into three build targets that combine into one published site:

flowchart LR
  src[Sources]
  src -->|cargo| native[Native game]
  src -->|web build| landing[Landing + wiki]
  src -->|trunk| wasm[Bevy WASM game]
  landing --> pages[GitHub Pages]
  wasm --> pages
  pages --> root["/ (landing)"]
  pages --> play["/play/ (game)"]

Local web preview

The published site is three builds stitched together - the content site at /, the WASM game at /play/, the generated mod portal at /mods/. Serving only one of them locally is what makes Play fall back to the landing page and the in-game Explore tab come up empty. Two scripts cover the two things you actually want:

nix develop -c scripts/serve-web.sh      # live dev: all three, watched
nix develop -c scripts/preview-web.sh    # one-shot static build of the deploy

serve-web.sh starts all three servers and proxies the other two onto the site’s origin, so a single URL has the deployed shape:

flowchart LR
  you([Browser])
  you -->|":UI_PORT/"| site["webpack dev server<br/>watches web/src"]
  site -->|proxy /play| game["trunk serve<br/>watches crates, src, assets"]
  site -->|proxy /mods| mods["serve-mods.sh<br/>watches webmods/"]

Everything rebuilds on save: edit a wiki page and the tab reloads, edit a crate and Trunk rebuilds the wasm, edit a mod and the portal is regenerated in place. --release switches the game to an optimized build. Ctrl-C stops all three.

Each server takes a random free port in 7000-7999, so several worktrees can serve at once - the banner prints the URLs. Pin any of them, or point the site at servers you started yourself:

VariableRead byEffect
NOVA_UI_PORTweb/webpack.config.jsFixes the site’s port.
NOVA_GAME_PORTscripts/serve-web.shFixes the game’s port (exported as TRUNK_SERVE_PORT).
NOVA_MODS_PORTscripts/serve-mods.shFixes the portal’s port.
GAME_DEV_URLweb/webpack.config.jsWhere /play is proxied. Default http://localhost:8080 (Trunk’s own default).
MODS_DEV_URLweb/webpack.config.jsWhere /mods is proxied. Default http://localhost:9000.

Two things are worth knowing before you go off-script:

  • Trunk needs explicit watch paths here. Its default (“the build target’s parent folder”, i.e. the repo root) never fires in this repo, so a bare trunk serve keeps serving the first build no matter what you edit. serve-web.sh passes --watch for each real input (crates, src, assets, credits, build, index.html, Cargo.toml, Cargo.lock).
  • The portal must be same-origin with the game. The wasm build derives its portal base from window.location, so under /play/ it fetches <origin>/mods. That is why the site server proxies /mods, and why a cross-origin ?portal= override fails on CORS. See Publish a mod.

preview-web.sh is the other half: no dev servers and no proxies, just trunk build + npm run build + gen-portal.py assembled into web/dist and served statically on :8090. It does not watch anything, but it is the only local check of the real deploy layout - run it before a release.

Regenerating the web screenshots

The site’s .figure blocks ship as placeholders; the real screenshots are captured in-engine and packaged into web/src/assets/ by scripts/gen-web-screenshots.py. Each figure auto-upgrades to its image at runtime once the asset exists (progressive enhancement in web/src/site.ts), so no HTML edit is needed - just drop the file in.

Capture and package in one command:

nix develop --command scripts/capture-web-shots.sh              # every producer
nix develop --command scripts/capture-web-shots.sh screenshot_railgun  # or a few

It stages into target/shots, runs each producer under Xvfb, and then packages with scripts/gen-web-screenshots.py. It is the stills half of a pair: scripts/capture-web-media.sh does the same for the webm loops, and scripts/capture-lesson-media.sh the same again for the training handbook’s demonstrations - the lesson_* producers, each writing one lesson’s still or 4x5 sprite sheet into assets/base/training/<lesson>.webp. That one ships WEBP, not PNG: the Lessons pane draws a demonstration about 900 logical pixels wide, so a sheet carries 960x540 cells (3840x2700 in all), and at that size a lossless sheet is about 1.6 MB against about 150 KB encoded. Every lesson the base handbook authors has a producer; one authored WITHOUT one keeps the placeholder scripts/gen-lesson-media.py draws - lossless WebP, at the lower resolution it is drawn at - and that generator REFUSES to redraw over captured footage, so the two can be run in either order. Both point NOVA_MODDING_CACHE_ROOT and NOVA_CONFIG_ROOT at the capture sandbox and stamp a base-only mod set BEFORE EACH producer, so neither an installed mod nor a producer that enables one on purpose (screenshot_scenario_picker does) can dress the next shot.

The capture examples run headless under NOVA_AUTOPILOT: each is one autopilot script whose steps pose the camera and shoot, and NOVA_CAPTURE is what makes those shot steps write 1920x1080 PNGs rather than drive straight through. The Python step validates each shot is 16:9, copies it in, builds the composite shots a single capture cannot make (e.g. devlog5-radar-stance-slots, two lock stances side by side) with a stdlib PNG codec, generates the section icons, and reports which shots have no capture example yet. Commit the resulting PNGs (they are content, like banner.png). Run python3 scripts/gen-web-screenshots.py --self-test to check the PNG codec (decode/resize/compose) and the report’s classification rules in isolation.

What is still missing

python3 scripts/gen-web-screenshots.py --report

Scans web/src/** for referenced assets/<name> images, diffs them against the manifest and the shipped assets, and prints each gap with an owner class:

ClassMeaning
capturableA game render a cataloged example can capture.
manualAuthored art (post-card thumbnails, icons, diagrams) - no automation produces it.
historicalA figure for an older shipped version; the current build can only approximate it.

Wrong-shaped and unreadable assets, assets the site never references, and staged PNGs the manifest does not declare print the same way. The report is ADVISORY: it copies nothing and always exits 0, so it is a worklist (for the owner: what art to draw; for automation: what a producer could capture), never a gate.

It closes with the GAME’s half of the same worklist: every Scenarios-picker thumbnail still on generated placeholder art, classed manual.

Scenario picker thumbnails

Every picker-visible scenario shows its own image in the details pane. Real per-scenario art is authored, not captured, so until it exists each scenario carries a deterministic placeholder - a 320x180 phosphor plate of its own title:

python3 scripts/gen-scenario-thumbnails.py           # write every PNG
python3 scripts/gen-scenario-thumbnails.py --check   # verify, write nothing

The PNG lands in the OWNING mod’s tree (assets/base/thumbnails/<id>.png, webmods/<mod>/thumbnails/<id>.png) and is referenced as self://thumbnails/<id>.png - never dep:// another mod’s art. Drop real art at the same path and nothing else changes; the report stops listing it, because the file no longer matches a fresh render. A new scenario adds one row to SCENARIOS in the script and one entry to its bundle’s resources; a scenario with no art of its own is what the coverage report lists.

Greeble meshes

The decorative fixtures a hull skin bolts onto its plates - vents, ribbing, blisters, masts - are GENERATED, not modelled. scripts/gen-greebles.py builds one .glb per JSON recipe in scripts/greeble-recipes/ into assets/base/gltf/greebles/, all committed:

python3 scripts/gen-greebles.py             # write every .glb
python3 scripts/gen-greebles.py --check     # verify, write nothing
python3 scripts/gen-greebles.py --self-test # internal checks, no I/O

A recipe is a palette plus a list of primitives - box, cylinder, taper, ribs, disc - each with a size, an at offset and an optional rotate. Solids compose by overlap, not by CSG. Adding a piece is a new recipe file plus one entry in assets/base/base.bundle.ron’s resources; no code changes.

Pieces are authored in the PLATE’s frame (the unit cell, out along +Y, y = 0 the mounting face) and the generator refuses anything behind that plane, wider than half a cell, taller than its declared budget, or over 200 triangles. The build is byte-deterministic, which is what --check gates: generated art that churns turns every unrelated diff into a binary one.

The three mesh scripts (gen-greebles.py, cut-obj-into-hulls.py, cut-obj-into-parts.py) share ONE hand-rolled, stdlib-only glTF writer, scripts/nova_glb.py. Prove a change to it kept the committed art intact by re-cutting a ship and diffing against webmods/the-ledger/gltf/parts/<ship>/, which is byte-reproducible from its recipe.

Eyeballing the site

npm run ci proves the bundle compiles and the theme tokens are in sync; it proves nothing about how a page LOOKS. For any styling, layout or readability change, capture the pages and look at them:

nix develop -c scripts/shoot-web-pages.sh target/web-shots

It builds web/, serves web/dist on a free port and drives headless chromium over the six page kinds (landing, news index, a news post, tutorial, wiki index, a wiki page with code + a table + mermaid) at desktop and mobile widths, writing <kind>-<width>.png plus a manifest.txt naming the commit. For a before/after, run it once per commit into two dirs and compare the matching pairs at identical crop and scale - a comparison at two different zooms shows you the resize, not the change.

The theme is shared with the game

web/src/style.css and crates/nova_ui/src/theme.rs both mirror the :root block of web/design/nova_ui_rework_poc.html - the interface palette and its control vocabulary. That PoC is the single source: change it first, then both consumers.

The PoC ships two skins and the site wears only one. Everything the site draws comes from the PHOSPHOR skin (the PoC’s body[data-skin="phosphor"] widget zoo): flat translucent green fills, 1px phosphor hairlines, 2px corners on controls, glow instead of bevel, and a solid --phosphor inversion for the primary state. The light-3D HARDWARE vocabulary (--face, --rim, --undercut, --well) stays in :root only to keep the mirror exact - it must be consumed nowhere.

web/tests/theme.test.ts (part of npm test, and so of npm run ci) parses the PoC and style.css and fails if the site’s tokens go missing or drift in value, if the phosphor vocabulary stops being consumed, or if any hardware material token is read outside :root.

Run verification (probe)

The run-harness is two crates split at the process boundary: nova_probe (crates/nova_probe/) links into the example and collects the evidence, and nova_probe_cli (crates/nova_probe_cli/) is the host side. Together they drive an autopilot example, records what happened (correctness) and what it cost (performance), and assembles one reviewable report. The POST-FEATURE CHECK - “did my change break behavior or perf?” - is one command:

cargo run --features debug probe run system_player_path            # clean + frame time + trace -> report
cargo run --features debug probe run system_player_path --correctness-only # clean behavioral evidence only
cargo run --features debug probe run system_player_path --samply   # + named flamegraph
cargo run --features debug probe run system_player_path --baseline probe-runs  # FPS deltas vs nearest prior commit
cargo run --features debug probe run system_player_path --repeat 5  # gated repeat set -> a usable worst frame
cargo run --features debug probe run system_player_path,system_scenario_grammar   # comma list -> aggregate index
cargo run --features debug probe run systems            # a whole category
cargo run --features debug probe run --all               # the whole fleet
cargo run --features debug probe scenario tutorial       # a SCENARIO by id, no example involved
cargo run --features debug probe scenario assets/base/scenarios/tutorial.content.ron  # ... or by file

It runs the example headless (throwaway Xvfb; --display :0 to reuse yours - and note that a software X server is not free, see Measuring performance), captures the run timeline + continuous invariants + the log into probe-runs/<short-commit>/<example>/ by default (or <out-base>/<short-commit>/<example>/ with --out <out-base>), optionally adds the profiled and samply passes (separate builds - tracing overhead never touches the clean numbers), and renders report.html + checks.json with a provisional OK/WARN/FAIL/NO_DATA/UNPROBEABLE the reviewer confirms. Every run dir carries a probe-run.json manifest (identity, full git SHA, passes, outcomes); probe report only re-renders dirs that have one. The commit root also gets index.html, index.json, and probe-all.json, even when the spec names one example. --correctness-only runs only the clean pass: timeline, invariants, autopilot assertions, completion, reached-Playing, and log checks remain armed, while frame-time and traced passes are omitted. CI uses this mode; release verification uses the full run. Three verbs are the whole surface - run, scenario and report - and each takes -h/--help, as does the root.

probe scenario takes those same passes to a scenario, with nothing in between: the SHIPPED GAME BINARY is the program, launched with --scenario <id> or --scenario-file <path.ron> and carrying the probe collectors under its debug feature. A positional ending in .ron is a loose content file, registered for that run whether or not it is installed - so a scenario a contributor or a mod author is still writing is measurable without adding it to any catalog. Its self:// art resolves against the nearest enclosing *.bundle.ron folder, the same place the merge would have resolved it. There is no spec to expand and no aggregate: one subject, one run dir, the same report.html + checks.json. Measuring a scenario needs NO example, no [[example]] block and no Rust file - that is the point of the verb.

Every run spec resolves to a list. A single example is just a one-item list; comma lists, category dir names, and --all expand against the [[example]] catalog and run sequentially with continue-on-failure. The status index lives above the example dirs: index.html (one row per example - verdict, measured n/total, one column per check, duration, a link to its report), index.json (the machine mirror), and probe-all.json (the re-render gate). The aggregate verdict is the WORST row; the exit code mirrors it. --all runs the whole catalog, and a bare probe run errors with the catalog listing rather than starting a fleet sweep by accident. Categories take single-digit minutes warm; --all is the pre-release/nightly sweep (roughly half an hour). --baseline <base> searches <base> for the nearest previous commit-hash directory in git history, ignoring compatibility folders such as before, then each example compares against <base>/<previous-short-commit>/<example>/frametime.csv when present. Without --baseline, probe searches the same base used by --out, defaulting to probe-runs.

Probe runs are profile-sandboxed: a run measures a commit, so it must not depend on your desktop profile. Every native child run is pointed at an empty, probe-owned profile under its own run dir - profile/mods (NOVA_MODDING_CACHE_ROOT, the downloaded-mod cache and its installed.mods.ron), profile/data (XDG_DATA_HOME) and profile/config (XDG_CONFIG_HOME, where enabled_mods.ron and settings.ron live) - and the tree is wiped at the start of each run. settings.ron is belt and braces there: every probe child run carries NOVA_AUTOPILOT, which makes the settings store inert, so a run reads no settings.ron at all and writes none. The sandbox is what isolates the mod state. Without it, a mod cached in a structure an older commit cannot parse, or a saved enabled-mod set, fails or shifts a run for reasons unrelated to the code under measurement. Shipped content is untouched: assets/ and assets/mods.catalog.ron load exactly as they do for a player, only YOUR saved state is swapped out. To probe your real installed mods, export the variable yourself - probe preserves any of the three it finds already set, and prints which ones it left alone:

NOVA_MODDING_CACHE_ROOT=~/.local/share/nova-protocol cargo run --features debug probe run system_player_path

XDG_CACHE_HOME is deliberately NOT redirected (the shader cache lives there, and throwing it away each run would make FPS numbers incomparable). The XDG pair is how the dirs crate resolves on Linux, the supported probe host; nova_probe_cli::native::profile_sandbox has the details.

Frame-time capture, the repeat set and its validity gate, the fixed-step and Xvfb traps, the frame-cost and census breakdowns, the preset sweep and the profiled pass all live on Measuring performance. This section stops at the run and its verdict.

Run timeline (correctness recording)

nova_probe also records WHAT HAPPENED during a run: set NOVA_PROBE_TIMELINE=<out.jsonl> on any example that adds nova_probe::nova_timeline() - which NovaProbePlugin does unconditionally, so that is EVERY cataloged example - and the run appends one JSON object per line: every GameStates/pause transition, every fired scenario event with its payload (kills, area enter/exit, locks), every scenario-variable change (old/new), plus the beats the autopilot script pushes itself via nova_probe::probe_marker. Entries are flushed as written, so a panicked run keeps everything up to the panic. Compare runs by ORDER and VALUES, not timestamps (wall-clock and frame counts vary across hosts):

NOVA_PROBE_TIMELINE=/tmp/run.jsonl NOVA_AUTOPILOT=1 \
  cargo run --example system_player_path --features debug

The timeline is native-only (no fs in the browser) and inert without the env var. It is the correctness half of the run-harness whose performance half is Measuring performance; the run report below renders both.

Continuous INVARIANTS ride the same stream: set NOVA_PROBE_INVARIANTS=1 (or =strict to panic on the first violation) on a wired example and every frame asserts what the engine guarantees - health within 0..=max and finite, velocities finite and nothing more (manual flight is Newtonian, so no speed is absurd), scenario Number variables finite, registered monotonic variables never decreasing (opt-in per example: system_player_path registers target_down/leg, system_scenario_grammar seven counters and latches, system_cinematic eight scene latches, system_outcomes hostile_down), and a total entity-count leak bound. A monotonic is one-way within a SCENARIO LIFE, not for the process: the memory is forgotten on ScenarioLoaded, so an example that replays through its loop point re-seeds its latches without taking a false regression. Violations warn, land on the timeline as kind: "invariant" entries, and feed the report’s invariants held check.

The run report (one verdict surface)

run_report assembles a RUN DIRECTORY - whatever the passes above dropped into it (timeline.jsonl, frametime.csv, trace.json, run.log, each optional) - into a self-contained report.html plus a machine-readable checks.json:

cargo run --features debug probe report <run-dir>... [--baseline <old-run-dir>]

Auto checks produce a provisional OK/WARN/FAIL/NO_DATA/UNPROBEABLE (process exit from the run manifest, run completed, reached Playing, invariants held, FPS vs baseline as a soft gate, log scan, artifacts loadable); a check whose capability the example never declared is N/A - “not claimed” - and an unresolvable one is SKIPPED - “not measured”; neither means “held”. checks.json pairs the verdict with a measured: n/total figure plus per-check structured data. A present-but-unloadable artifact degrades that one artifact to absent and FAILS artifacts_loadable with the reason, rather than aborting the report the failure would have been visible in. Zero evidence is NO_DATA (nonzero exit) and a run that graded no declared capability is UNPROBEABLE (zero exit - the sanctioned no-probe-plugin opt-out, gated on its smoke checks alone), FPS improvements PASS (only regressions WARN - frame numbers are host-noisy), a hung run is killed and still produces a FAILing report, and the report ends with a reviewer checklist: the final OK/NOT-OK is a human’s or an agent’s call, off checks.json without parsing HTML.

Versioning and release

  • Version: workspace.package.version in root Cargo.toml; crates inherit it.
  • nova_info::APP_VERSION comes from the APP_VERSION env var via build.rs. A --features debug build appends the short commit hash (0.12.0+9da8b055, or +unknown outside a checkout), so the status bar names the revision. The build script tracks .git/HEAD and its ref, so a new commit restamps it.
  • Packaging assets (icons, installer, .app) live under build/.

The nix package

The flake packages the game as well as the development shell, so a player needs neither a checkout nor a toolchain:

nix run github:alexjercan/nova-protocol         # build it and play
nix profile install github:alexjercan/nova-protocol
nix build .#default                             # ./result/bin/nova-protocol

Three outputs, and which one you want depends on what you are doing:

outputwhat it is
packages.defaultthe wrapped game - the one to install
packages.nova-protocolthe same derivation, under its own name
packages.nova-protocol-unwrappedthe bare cargo binary

The unwrapped binary finds no assets and opens no window on its own. It is split out so that editing the desktop entry, the icon or the asset wiring costs a second instead of a fat-LTO relink of the whole Bevy graph. The wrapper is what makes it a game:

  • assets/ and credits/ are installed under share/nova-protocol, and BEVY_ASSET_ROOT points bevy’s reader at them. Without it the reader falls back to the directory the executable sits in, which in the store holds the binary and nothing else. It is set as a DEFAULT, so a modder can still point a packaged build at a working tree.
  • The libraries Bevy opens with dlopen - vulkan, wayland, X11, xkbcommon, alsa, udev - go on LD_LIBRARY_PATH. The linker never records them, so a packaged binary finds none of them without this. It is the same list the development shell exports, shared in flake.nix rather than written twice.

The package builds with --profile dist, the profile .github/workflows/release.yaml ships, so nix run gets the binary a player gets. Fat LTO and one codegen unit: budget half an hour for a cold build.

webmods/ is NOT in the package. Those are the portal’s development fixtures, served by scripts/serve-mods.sh; the mods:// source reads installed mods out of the player’s data directory (~/.local/share/nova-protocol/mods), which no store path can hold.

The package’s toolchain is rust-project.toolchain, set to the same rustNightly value the development shell uses. Left alone, rust-flake resolves its own from rust-toolchain.toml, and the package and the shell could drift apart on a nix flake update - so there is still ONE pin in flake.nix, the one the comment beside it names.

The desktop entry

nix profile install also installs share/applications/nova-protocol.desktop and the icon into the hicolor theme, so rofi -show drun, wofi, and the GTK and KDE menus list “Nova Protocol” with its mark. ~/.nix-profile/share is already on XDG_DATA_DIRS, so nothing else has to be wired; nix run and nix build do not install it, because neither adds anything to a profile.

The icon is rendered from web/src/favicon.svg, the site’s brand mark, into the eight hicolor sizes plus the scalable SVG. A launcher resolves Icon=nova-protocol by name against the theme, so the sizes have to exist as files: an icon theme with no scalable support finds nothing otherwise. The art under build/ is still bevy_game_template’s placeholder bird and is deliberately not used.

Cutting a release

Pushing a tag v[0-9]+.[0-9]+.[0-9]+* triggers release-flow (.github/workflows/release.yaml). Steps, on master:

  1. Check the documentation surfaces are current (see Keeping docs in sync): this book builds clean (mdbook build) and the pages the cycle’s changes touched are updated.
  2. Bump workspace.package.version in root Cargo.toml.
  3. Refresh Cargo.lock: cargo metadata --format-version 1 >/dev/null.
  4. Update CHANGELOG.md (Keep a Changelog, one concise line per entry): promote [Unreleased] to [<version>] - <YYYY-MM-DD>, leave a fresh empty ## [Unreleased] on top, merge any duplicate section headings that grew during the cycle, and update the compare links at the bottom (repoint [unreleased], add the new [<version>] line).
  5. Commit exactly those three files: git add Cargo.toml Cargo.lock CHANGELOG.md && git commit -m "chore(release): vX.Y.Z".
  6. git tag vX.Y.Z (CI reads the tag for the release name).
  7. git push origin master && git push origin vX.Y.Z.
  8. Watch the run (gh run watch), then check the GitHub release page and consider adding summarized release notes (gh release edit vX.Y.Z --notes-file ...).
  9. Write or expand the release News post (see “Writing the release news post” below) and land it in web/; sync any wiki pages the cycle changed (see Keeping docs in sync).

The workflow uploads four assets to a release named after the tag: macOS universal .dmg, Linux .tar.gz, Windows .zip, and a wasm-opt’d web zip. The macOS job writes CFBundleShortVersionString and CFBundleVersion from the tag, then refuses to create the DMG unless Info.plist declares the copied nova-protocol executable. The workflow can also be re-run via workflow_dispatch with a version input.

Deploying a release to itch.io

The manually dispatched deploy-itch workflow (.github/workflows/deploy-itch.yaml) uploads an existing GitHub release to alexjercan/nova-protocol. It never rebuilds the game. The job downloads the four assets named above, and scripts/prepare-itch-upload.sh verifies and extracts them into the stable Butler channels windows, osx, linux, and html5. On macOS, hdiutil mounts the release DMG and ditto preserves the app bundle metadata. On Linux, the Nix development shell supplies 7-Zip to extract the HFS+ image; the script restores the declared app executable’s mode. Windows and web ZIPs use ordinary unzip. The macOS preparation checks that CFBundleExecutable names a real executable, and the web preparation requires index.html and the wasm file at the upload root. Production CI stays on macOS and uses the native path; the Linux fallback makes local preparation and preflight available without Apple tools.

Configure the protected itch-production GitHub Environment before the first run:

  • secret BUTLER_API_KEY: an itch.io API key for the alexjercan account;
  • variable ITCH_TARGET: alexjercan/nova-protocol;
  • variable BUTLER_VERSION: the exact Butler release to install;
  • variables BUTLER_SHA256_DARWIN_AMD64 and BUTLER_SHA256_DARWIN_ARM64: the SHA-256 sums of that release’s official macOS archives.

Use the Butler download documented by itch.io to choose the version, download each macOS architecture archive from https://broth.itch.zone/butler/darwin-<arch>/<version>/archive/default, and record its shasum -a 256 output in the matching Environment variable. The workflow refuses a floating Butler version or an archive whose checksum does not match.

After the GitHub release and Pages deployment are complete, keep the itch.io page in Draft and run:

gh workflow run deploy-itch.yaml -f tag=vX.Y.Z
gh run watch

The protected Environment supplies the explicit approval boundary. The run records release checksums and the prepared file inventory as an Actions artifact. Confirm itch.io detected each platform, mark html5 playable in the browser if needed, and launch all four channels on clean target systems. Only then change the page from Draft to Public. A later Butler push updates a public channel immediately, so a failed or mismatched channel blocks another deploy.

Writing the release news post

Every release cycle gets one News post on the site (/news/, markdown under web/src/news/). News is the merged devlog + release notes: one post per FEATURE release (v0.X.0). Patch releases do NOT get their own post - they fold into the parent feature post’s ## Point releases section (v0.5.0’s post covers v0.5.1 and v0.5.2). The terse per-version list stays in CHANGELOG.md; source the post’s content from the cycle’s CHANGELOG.md sections.

A News post follows the spirit of Factorio’s Friday Facts: a narrative lead, then a handful of feature-by-feature ## sections written candidly (the reasoning, the dead-ends, the piece you are proudest of), leaning on screenshots, and - where a devlog video exists - an optional ## Watch the devlog companion near the top (the written highlights must stand on their own; the video is an extra). Do not just restate the terse CHANGELOG.md.

Adding a post touches three places (mirror an existing post such as web/src/news/0.5.0.md):

  1. Write the post at web/src/news/<version>.md (e.g. 0.6.0.md). The page shell (newsPostShell in web/markdown.js) renders the H1, the <date> // v<version> meta line, and the footer (the Discussions prompt plus the CHANGELOG.md pointer and “All news” link), so the markdown is just the body: the H1 (# vX.Y.0 - <title>), the lead, the ## sections, .figure placeholder blocks for screenshots to capture later, an optional .video-embed companion, a .callout.callout--breaking block for any format break, and a closing ## Point releases section for the cycle’s patches. Do not add a footer or a CHANGELOG.md link yourself - the shell adds them.
  2. Register it in web/webpack.config.js: add an entry to NEWS_POSTS (newest-first) with slug/version/date/description. The plugin list and the historyApiFallback rewrite both derive from NEWS_POSTS, so no other wiring is needed.
  3. Add a .post-card to web/src/news.html at the top of .post-grid (newest-first): a media thumbnail plus the date/version, title, and one-line excerpt. For the thumbnail, use the YouTube thumbnail (https://img.youtube.com/vi/<id>/hqdefault.jpg) if the release has a video, otherwise the .post-card__ph placeholder naming assets/thumb-news-<version>.png.
  4. Rebuild and check it: cd web && npm run ci (format check, lint, test, build).

Comic deployment

npm --prefix web run build:story builds only released comics into web/dist-story/story/. Its local scripts, images, favicon, and font assets stay under /story/. Shared theme/header sources are compiled into that snapshot; unrelated website pages are neither built nor published. The shared Google Fonts import remains external. Normal serving still uses the complete website and labelled drafts; there is no second development server.

Pushing a comic-* tag starts deploy-comic. A manual run accepts an existing comic tag for a retry or rollback. The workflow resolves the tag to a commit, builds once, and passes the artifact and exact commit to its publisher. The tag names a library snapshot, not a filter: publication metadata selects episodes. Game vX.Y.Z tags and version numbers remain separate. The comic authoring README owns the publishing procedure and first-rollout checklist.

The full Pages workflow uses build:site, which does not execute or emit comics. Both publishers use scripts/deploy-pages.py. It fetches the latest gh-pages under a shared github-pages-publish job lock, builds a temporary Git index and working tree, and commits a scoped merge without changing the source checkout’s index. A comic deployment replaces only story/; a site deployment preserves it. Both preserve CNAME and ensure .nojekyll. Non-fast-forward pushes fail. Pages hosting must use the gh-pages branch root. Both jobs explicitly request and wait for a Pages API build with a write-scoped Pages token.

Bootstrap the new comic directory before a site-only deployment can remove old root-level reader files. The guard requires story/release.json, which records the comic tag and source commit. Use the current site’s workflow, not a workflow from an older game tag that predates the ownership split. Serial publishing is not a FIFO release queue; wait for one deployment before starting another.

npm --prefix web run test:deploy checks isolated builds with released/draft fixtures and local bare-Git publishing with mocked HTTP. It never publishes to GitHub. Normal npm run build still builds the complete released-only website for checks; it is not the input to either scoped publisher.

Writing a story comic

web/src/comics/ is the single home for maintained comic sources. Each season has comic.json; each episode has episode.json, an ordered episode.ts, one TS file per page under pages/, and Python panel drawings under art/. The directory names provide /story/season-1/episode-1/, with page ids in the fragment. Shared faces, ships, and colors remain in scripts/nova_illustration/. web/src/comics/README.md owns the DSL and build contracts. Public exporters must not import task files.

/story/ lists seasons only, in reading order. Each season has one illustrated row with its title, summary, selected episode count, and labelled local draft count. Click it to open /story/<season>/, which lists that season’s episodes in authored order. Each episode has one clickable row and one thumbnail; drafts show how many pages are illustrated. There are no duplicate reading shortcuts or repeated season cover above the episodes.

The season page returns to the library through Story. The reader returns to its episode list through the season title, such as Season 1; Pages opens its page chooser. The phosphor shell surrounds full-color art.

Run npm --prefix web run serve and open /story/ at the printed loopback URL. Normal local development includes labelled drafts. Source edits regenerate pages and reload the same page fragment. It uses web/.cache/story/development/site, not web/dist, and does not serve stale files from a prior disk build. scripts/serve-web.sh starts this same server with the game and portal.

npm --prefix web run build always selects released episodes, even with webpack in development mode. Serving with --mode production also excludes drafts. Selection happens before generation: an excluded episode’s code never runs, and none of its routes, assets, dialogue, or script enters the output. Only referenced assets are emitted; comic directories are not broad copy/import inputs. With no released episodes, the public archive has a plain empty state.

Every metadata field is required. comic.json gives the unique positive season sequence, title, summary, and ordered episode directory ids. episode.json gives title, summary, cover scene id and alt text, TS entry filename, explicit Python art-module paths, complete pageCount, and publication (draft or released). Unknown fields, missing sources, unregistered directories, duplicate ids, and invalid generated data fail. Released episodes must form a prefix of reading order and have exactly their authored page count. A season can contain released episodes and the next draft. Publishing changes metadata after approval; sources stay in place. Comic release remains independent of campaign implementation.

The TS page language owns action, dialogue, screen labels, narrative location cards, and page layout. Dialogue uses stable ids; balloon placement is keyed separately. Unillustrated pages remain TS scripts without fake art references. The shared build engine derives reader definitions, transcripts, and a reading copy named SCRIPT.md in the generated cache. That Markdown is not another maintained source. Unillustrated story text never enters a reader manifest.

Python art modules register named Scene functions. The common builder exports only referenced standalone panel SVGs; it neither draws nor strips dialogue. Blank named text slots preserve labels’ painter order under hands and equipment. Raw SVG is checked before export, and the browser checks it again on import. comic-panels.ts composes the page frame, panels, labels, cards, and measured balloons. comic-renderer.ts owns the DOM; ComicPlayer in story-reader.ts owns fitted playback, contents, deep links, and input. New pages update automatically. Route changes and build-engine JS changes need a server restart.

Open image exports the displayed full page. The compact Panels and Transcript toolbar buttons open scrollable modals without taking space from the page. Panels offers each raw art SVG and its lettered export. Close or Escape restores focus to the opening button. These exports share the reader’s measured layout. Automatic wrapping uses actual font metrics; explicit word-count breaks retain accepted lettering. The local Art only control hides speech. Automated browser checks inspect measured layout without a reader control or diagnostic overlay. Face checks use contour bounds, not hair boxes, and do not replace visual review. Full-color pages have no CRT tint, scanlines, or shade overlay. Older typed HUD helpers remain internal renderer building blocks, not a demonstration or a second story authoring workflow.

Run cd web && npm run ci, then inspect the season library, each season’s episode list, and reader routes at desktop and narrow widths, including a project URL prefix. Check browser Back and the labelled return links at both levels. The reader displays one page. Buttons, contents, arrow/Page keys, wheel, and a single-finger vertical swipe change pages. Modified zoom gestures are not page turns. Open image uses the browser’s full-size image view and native zoom for the complete composition. Transcript exposes the selected page’s derived text in its modal; scrolling it does not turn the underlying page. Contents hidden from view are also hidden from keyboard navigation. Mobile image fitting is an overview, not a claim that small landscape lettering is readable without zoom or text.

Contributing a change

The everyday loop for landing a change:

  1. Branch off master. Work items are tracked as tasks/ markdown (see Task tracking below); check the backlog first.

  2. Build and format: cargo check && cargo fmt before you commit. Do NOT run cargo test or cargo clippy locally unless asked - they are slow and CI is the source of truth; when you skip them, say so.

  3. Drive it with an example. For a substantial feature, add or extend the examples/ example that exercises it, with a harnessed autopilot assertion (see Examples) - this repo prefers a runnable example over an isolated unit test.

  4. Open a PR. CI (.github/workflows/ci.yaml) runs on pull requests and pushes to master, so the integrated tree is revalidated after merge: cargo fmt --check, cargo clippy --workspace --all-targets --features debug -- -D warnings, cargo test --workspace --features debug, then a windowed seven-example probe run --correctness-only smoke under Xvfb/lavapipe plus the nova_autopilot example test under Xvfb. Three more jobs run in parallel with that one: a default-features cargo check --workspace --all-targets under RUSTFLAGS=-D warnings, a wasm32 cargo clippy --workspace --exclude nova_probe_cli (the host harness has no meaning in a browser), and a dependency-license gate. Those two exist to catch dead code and unused imports that only appear with debug off or on wasm - neither configuration is otherwise built. All of it must be green to merge.

    The curated release set lives in .github/workflows/probe-full.yaml, a five-shard suite with no push or PR trigger. release.yaml calls it and every platform build waits on it, so a tag cannot ship past a red selected example; run it by hand from any ref with workflow_dispatch. Both runtime lists are named, so scripts/check-probe-suites.py (a step in the check job) rejects unknown and duplicate names, empty sets, and smoke examples outside the full shards. It does not require every [[example]] to run in CI; the independent catalog drift test checks disk against Cargo.toml, and the default all-target check compiles the remaining examples.

    The wasm job is CLIPPY rather than check, and it points CLIPPY_CONF_DIR at ci/wasm-clippy/, whose clippy.toml bans the std APIs that COMPILE for wasm32 and then panic in the browser - a class of bug no cargo check on that target can see. Those bans are correct only there: natively bevy::platform::time re-exports std’s clock, so the same list would flag correct code in the main job. That is why the config lives outside the repository root, and why there is no root clippy.toml.

House style is in AGENTS.md at the repo root - Rust, Bevy, Nova, comments, documentation, changelog and web, each a section. Commit messages are plain and use ASCII punctuation only. Releases are a separate, tagged flow (see Cutting a release).

Task tracking

Work items live as markdown under tasks/ (managed with the tatr CLI), so they are versioned alongside the code. Check the backlog before starting and close tasks when done. Each task has its own folder holding its TASK.md plus any task-scoped records (SPIKE.md, REVIEW.md, RETRO.md, NOTES.md). Multi-task plans are tatr tasks too - a release plan is a task with the strand breakdown in its body (or a release/meta tracker task linking the per-strand tasks). docs/ is the source of this book, not scratch: transient working files live outside the repo, and task-scoped records live in tasks/<id>/.