Development
Toolchain
- Rust nightly, pinned by
rust-toolchain.toml(with rustfmt + clippy). - NixOS:
nix developgives the toolchain, thewasm32-unknown-unknowntarget, all system libs Bevy needs (udev, alsa, vulkan, X11/wayland),trunk, andsccache(see fast worktree builds below). Without Nix, install those yourself. Barecargois not on PATH under Nix: run every cargo/rust command vianix develop --command <cmd>(the commands below assume you are insidenix develop). - Playing it, as opposed to working on it:
nix runbuilds and launches the packaged game with no checkout and no development shell. See The nix package.
Everyday commands
cargo run # the game (boots into the main menu)
cargo run -- --scenario tutorial # the game, straight into one scenario
cargo run --features dev # + debug tooling (inspector, wireframe)
cargo run --example system_scenario_grammar # run an example
cargo build --release # release profile: opt=s, lto, stripped
cargo check && cargo fmt # before committing
cargo test --workspace # full suite (CI runs this; skip locally unless asked)
cargo run content lint # validate content: refs + ship parts + balance + input overlaps (also: gen)
cargo run --features debug probe run system_player_path # run-harness check (correctness + perf)
Notes that keep the suite honest and fast:
- Use
cargo test --workspace, never barecargo test: unit tests live in the member crates, so the bare form runs almost nothing and gives false comfort. cargo testtakes ONE filter and one-pper invocation; separate runs for separate filters or packages.- For a timed headless example run, build first, then time only the run
(
cargo build --example X --features debug, thenNOVA_AUTOPILOT=1 timeout N cargo run --example X ...). A cold build inside the timeout burns the window. - Struct-field changes:
cargo check --workspace --all-targets, or examples and tests stay silently broken.
The dev profile uses opt-level = 1 for our code, 3 for dependencies: slow
first build, fast iteration. split-debuginfo = "unpacked" +
debug = "line-tables-only" keep link-time RAM around 20 GB instead of 40
(one Bevy-sized binary per test/example target); set debug = true temporarily
if you need a debugger.
Worktree builds (fast via sccache): a fresh sprout worktree starts with an
empty target/, but the devshell wires sccache as RUSTC_WRAPPER (with
CARGO_INCREMENTAL=0, which sccache requires) so it does NOT pay a full cold
build. sccache caches each rustc invocation’s output keyed by a hash of the
source content plus flags plus compiler version, in a shared cache
(~/.cache/sccache). Unchanged deps (bevy, avian, the whole pinned tree) are
100% cache hits across worktrees; only changed nova_* crates recompile.
Measured on 2026-07-21 (game binary, quiet host):
| build | wall clock | sccache stats |
|---|---|---|
| cold (empty cache) | ~6m45s (405s) | 517 misses / 0 hits |
warm (cargo clean, same source) | ~38s | 517 hits / 0 misses (100%) |
The warm number is what a fresh sprout worktree gets once the shared cache is warm. Recipe from a new worktree:
cd "$(sprout new <branch>)"
nix develop --command cargo build # warm-cache: seconds, not minutes
nix develop --command sccache --show-stats # confirm the hit rate
Still do NOT point CARGO_TARGET_DIR at another checkout’s cache: cargo keys
fingerprints on crate name + version + features + profile + rustc, NOT the
source path, so two checkouts alias each other’s artifacts in a shared dir and
a worktree binary can silently link another checkout’s code (the stale-binary
incident). Each worktree keeps its OWN target/; sccache is the SAFE way to
share compilation because its cache key IS the source content - there is no
path where a worktree links code from different source. That content-keying is
also why sccache is transparent to CI: an empty cache is just a cold build.
The devshell sets CARGO_INCREMENTAL=0 shell-wide (sccache is incompatible
with incremental). This costs the main checkout’s iterative edit-rebuild loop
its incremental speedup; the fresh-worktree-per-task agent workflow only ever
does cold-shaped builds, so it is pure win there. A sprout-scoped variant
(export the wrapper only in sprout shells, keep the main checkout on
incremental) is possible as a nix.dotfiles follow-up if the main-checkout
iteration cost bites.
Features
debug- the wholenova_debugplugin (inspector, wireframe, overlays) plusbevy/track_location.dev- alias fordebug.trace-bevy/trace+bevy/trace_chromefor span traces; the probe harness builds--features debug,tracewhen it needs one.
Debug tooling
cargo run --features dev compiles in nova_debug’s DebugPlugin
(crates/nova_debug/src/lib.rs), which adds the inspector, the wireframe
toggle, and the section/gravity debug overlays. The overlays are gated on a
DebugEnabled resource toggled at runtime with F11
(DEBUG_TOGGLE_KEYCODE), so they can be flipped off without a rebuild. Note the
feature is spelled debug, with dev as an alias for it (root Cargo.toml);
--features dev and --features debug are interchangeable.
DebugPlugin also binds F12 (SCREENSHOT_KEYCODE,
crates/nova_debug/src/screenshot.rs) to a screenshot: it captures the primary
window and saves it to your Downloads directory as <unix-millis>.png. The
capture is intentionally not gated on DebugEnabled, so it works whether or not
the overlays are shown. Native only: the module is compiled out under
target_arch = "wasm32", which has neither a Downloads directory nor a wall
clock.
Three debug-only CLI flags exist, all parsed in src/main.rs and all compiled
in only under the debug feature. Two of them are the OUTPUTS-OFF pair -
--norender drops the renderer, --mute silences the speakers - and each has
an environment twin an example can be armed with, since an example has no
command line of its own:
-
--norender- build the app throughAppBuilder::headless(): no wgpu device, no window, no winit event loop, and none of the visual game plugins. The main schedule still ticks, so the simulation runs and a probe capture still counts frames - CPU ones, withrender_worldandgpureading zero.Nothing in a headless app ends the run. There is no window to close and no input of any kind, so it needs a driver:
--scenario <id>underNOVA_AUTOPILOT, orprobe scenario. A bare--norenderticks until it is killed, and that is by design rather than a hang to report.An EXAMPLE has no command line of its own, so the flag cannot reach it. The environment does:
NOVA_NORENDER=1makes everyAppBuilder::new()in the process assemble the same headless app, which is how a range runs without a GPU. Set to anything, including empty; unset means render.AppBuilder::headless()ignores it, being already headless.NOVA_AUTOPILOT=1 NOVA_NORENDER=1 cargo run --features debug --example stress_point_defense cargo run --features debug probe run stress_point_defense --norenderHeadless is a speed option, not a substitute for a rendered run. With no device there is nothing to break: a duplicate-component panic, a material or pipeline failure, and the async-compile SIGSEGV
synchronous_pipeline_compilationexists to prevent are all invisible headless, andcargo checkdoes not see them either. Only a rendered run does. Run ranges headless for speed if you like; keep a rendered set as the canary. -
--mute- zero the audio output. The other half of the outputs-off pair: Xvfb hides the window but not the speakers, and nobody listens to a scripted run. It insertsHarnessMute(true)after the builder, so it wins over whatever the environment resolved. The volume SETTING is untouched, so persistence and the settings menu never see it, and a muted run saysnova audio: output muted for this runonce at startup.The environment twin is
NOVA_MUTE, which an example reads throughHarnessMute::from_env: set to anything but0it mutes,NOVA_MUTE=0forces sound even under a harness, and unset it mutes iff a harness variable (NOVA_AUTOPILOT,NOVA_CAPTURE) is set.cargo run --features debug -- --mute --scenario asteroid_field NOVA_MUTE=1 cargo run --features debug --example stress_bullets -
--debugdump- print the system schedule graph (viabevy_mod_debugdump) and exit. It dumps theUpdateschedule (debugdumpincrates/nova_debug/src/lib.rs).
Logging
The filter is built by log_filter_str in crates/nova_core/src/lib.rs. A
--features debug run puts the nova crates at DEBUG; a release run leaves them
at the plugin’s INFO default.
A new crate needs no filter change. Every workspace crate is named nova_*
and EnvFilter matches a directive’s target by PREFIX, so the single nova=
directive covers all of them. Do not add a per-crate directive: the list this
replaced named nine of twenty-two crates, and the thirteen it missed sat
silently at INFO while their neighbours were at DEBUG.
Pick a level by WHO needs the line and HOW OFTEN it fires, not by how interesting it felt while writing it:
| level | fires | example |
|---|---|---|
trace! | per ITEM - anything that scales with content | one line per spawned object, per section, per widget |
debug! | per OPERATION - one line for the whole batch | scattered 26 of 26 'gauntlet_rock_' object(s) |
info! | a person running the game wants it; should be rare | the probe’s own report lines |
warn!/error! | something is WRONG | a scenario names an id that does not resolve |
An expected-and-handled condition is not a warning. A batch summary carries a COUNT - a summary line without a number is barely better than the noise it replaced.
RUST_LOG directives are MERGED over this filter by bevy rather than replacing
it (bevy_log-0.19.0/src/lib.rs:406), so RUST_LOG=nova=trace restores the
per-item detail without unmuting wgpu and naga.
A headless run additionally clamps three bevy diagnostics it provokes by
construction - the missing render app, CompressedImageFormatSupport, and
gizmos noticing there is no RenderApp. All three are unreachable when a render
sub-app exists, so a rendering run keeps those targets at their normal level.
Examples
examples/ exercises one subsystem each, end to end; this repo prefers
runnable examples over isolated unit tests. The examples live in purpose
directories (bevy-repo style: category dirs, plain slug names), and the
[[example]] catalog in the root Cargo.toml (autoexamples = false) is the
single source of truth, listed in curriculum reading order.
The category contract
A category is not a folder - it is a promise about WHO an example is for. Pick the category by its audience, not by what it happens to spawn.
| Category | Who it is for | What probe does with it | Disqualifies an example |
|---|---|---|---|
playable/ | A HUMAN: somebody loads it and does the thing it demonstrates, through an affordance wired outside the NOVA_AUTOPILOT gate | runtime contract decides; native trace is automatic | its only affordance is the free-fly camera every cameraless scene already gets |
systems/ | The PROBE: a behavior staged and asserted, every claim named on the invariant roster | runtime contract decides; native trace is automatic | its product is a frame for human eyes rather than a verdict |
screenshots/ | The WEBSITE and the wiki: frames, webm loops, posed lineups, the frame-time baseline | runtime contract decides; native trace is automatic | its verdict is an assert, or a human is meant to drive it |
The test between playable/ and the other two: would a human loading this
expect to DO something? If the name promises a verb, it owes the verb. The test
between systems/ and screenshots/ is what a run PRODUCES - an assertion, or
a picture.
There are THREE categories and there is no fourth: an example that does not fit
one of them is miscategorised, not a new kind. Autopilot in a playable/
example is a SECOND driver, for captures and for the run gate - never the only
one.
Run policy is DECLARED by the example at runtime, through the probe plugins it
wires (nova_probe::contract); probe reads it back from probe-contract.json.
Every cataloged example is spawned (--all subtracts nothing), and one that
declares no capability grades UNPROBEABLE - the sanctioned opt-out, gated on
its smoke checks alone. The grading detail lives under
Run verification (probe); what each category proves
is the table above plus the per-block comments in the root Cargo.toml, and
review enforces it.
The catalog and the harness
What is on disk today, in reading order:
playable/- what a person loads and works. The hulls first:carve_asteroids(fly a PDC rig at a row of shipped-size rocks and hole one by hand) andwfc_arena(a match bench for wave-function-collapse hulls, with a lobby, a pause menu, a result board and a--ship TEAM:playerslot that puts you in one of them). Then the benches and galleries, each with its own keys:wfc_ships(Rre-rolls the collapsed row),shape_benchandblock_bench(Lcycles the style,Cstrips the cladding),railgun_wake_bench(the slug’s ionized wake at three speeds,1-3pick the lifetime policy, the arrows tune it,[]slow it down),greeble_catalog(a selection ring, a focus turntable, pedestal and cell-frame toggles),parts_viewer(a paged grid, a focus turntable and a reassembled recipe ship with an explode toggle),widget_zoo(everynova_uiwidget factory, live and clickable in both skins),compare_asteroids/compare_planets(the number keys re-dress the focus subject) andasteroid_kinds/planet_types(the whole authored range of each, side by side). Last, the mission-scale pair:first_shift_mapis a fixed belt layout with a route over it - the most populated authored scene there is - andfirst_shift_shipsposes eight shipped hulls in a row. All of them still walk and capture underNOVA_AUTOPILOT, which is what keeps them on the probe gate.systems/- the correctness ranges. Every name carries a prefix for the KIND of check it is -system_functionality,bug_a regression range for a defect that was found,stress_load - andexamples/systems/README.mdowns the rule. The section curriculum first:system_attitude_hold(PD attitude),system_thrust_and_plume(burn -> thrust + plume shader),system_hull_damage(damage -> destroy -> ship survives, and the mass properties the losses move),system_section_severing(a destroyed interior section leaves a real hole and the structure behind it drifts free as its own wreck body, at the speed its own size asks for),system_destruction_finale(every destructible body - gltf section, procedural section, multi-part turret, asteroid - breaking into its OWN art rather than generic cubes, on one budget),system_turret_gunneryandsystem_torpedo_launch(the weapon ranges, the latter also the PN lead-a-crosser deep-dive), andsystem_blast_penetration(explosive falloff, one section shielding the next and two salvos that cannot use the hole they make in the same tick) andsystem_railgun_lance(the spinal gun: a commit that outlives the trigger, a charge cue, one slug raking six plates in a single step, and the recoil that shoves the firing ship). Beside them, the ranges that read a figure off the HULL rather than off a constant, each staged on a skiff and a capital at once:system_hull_scaling(the two reference hulls’ live inputs and what the game derives from them - lock range, the fire gate, the size a hull burns at),system_collision_damage(a ram at three closing speeds, and the docking speed that costs nothing),system_ai_combat(what an AI ship does when it fights) andsystem_ai_evade(what it does when a gun is held on it: three jink legs, each the size of the hull flying it). Then the cross-cutting systems, every fixture aScenarioConfigwritten in Rust and loaded withLoadScenario:system_scenario_grammar(the scenario language - variables, events, filters, actions),system_player_path(a scenario played through the real input pipeline: lock, kill, travel-lock, GOTO),system_cinematic(a SCENE: the prompt and title card it puts on the HUD, the pause that freezes the way out, the skip the player presses, and the two endings the scenario hears in order),system_outcomes(die -> the Defeat overlay -> Retry -> a clean reload -> kill -> the objective and the CHECKPOINT -> Continue -> the chained scenario),bug_outcome_pause(who owns the screen and whether the world is running behind them: alt-tab pauses a fight and coming back does not resume, an outcome landing over the pause menu takes the screen, and a timed advance taken while the player is away hands its pause to the pause menu without one unpaused frame),bug_refused_scenario(a chapter the content gate refuses ends the one it replaces, cursor freed and Main Menu working),bug_failed_assets(a broken optional mod is quarantined and reported, and a broken base asset is fatal and says so),bug_neutralized_quiet(a wreck’s point defence stands down) andsystem_borrowed_battery(the Flight Computer works idle player PDCs). Then the interface, driven by synthesized pointer input:system_ship_editor(build a ship and inspect it, refusals included),system_field_controls(the inspector’s number rows: the unit a field’s declaration gives it, the step a drag on its name moves it by, and the floor that drag ARRIVES at where a typed value is refused),system_input_modes(one owner of the keyboard at a time - the same key pressed under a text field, the gallery, a pending rebind and none of them, each verdict a NEGATIVE read after a settle because a key that went nowhere raises no event),system_ui_scale(a world-anchored label holds its LOGICAL offset when the scale factor doubles and the window changes shape, and the top bar and the stage’s nameplates are read at every shape alongside it),system_hud_indicators(where a screen-projected indicator lands),system_menu_boot(the shipped boot flow),bug_menu_fallback(a menu with no clean backdrop to draw ends the scenario it came from and stands on one bare camera) andsystem_scenario_picker(the Scenarios picker: its pane split must not depend on the selection - real fonts, real taffy, which a headless unit rig cannot measure at all - and the row a pointer selects and plays is the scenario that comes up once the atomic load gate releases),bug_sandbox_soak(the editor sandbox entered and then left alone, holding one physics step to its own timestep),bug_carve_apply(one cut that severs a rock, and what the main thread pays to SWAP the result in - the range counts grids rather than milliseconds, so it reads the same on every box),system_interface(the Tab interface and the:modal over it, driven with keys only: TAB, M and:must return through Escape to the same pane with the clocks frozen and no pane or flight action firing on the transition frames),system_command_shell(the modal over flight, the pause menu, the main menu and the interface, including the rebind gate that keeps:shut while a ship section waits for its key) andsystem_headless_map_goto(a click on a Map blip in window space, then GOTO, asserted on the ship’s autopilot with no GPU). Finally the STRESS ranges, one file each:stress_bullets,stress_torpedoes,stress_point_defense,stress_one_structureandstress_many_structureshold a thousand rounds, a thousand guided torpedoes, one battery working a stream of inbound ordnance, a thousand sections on one body and a hundred bodies, each asserting exact counts, a drain to zero and a teardown that leaves nothing, with a frame-time capture riding along. Their scale constants are named and carry the comment that they must NEVER reflect real content;stress_point_defensealso readsNOVA_STRESS_PD_MOUNTSandNOVA_STRESS_PD_BAYS, so one build can be swept across scales without moving what it asserts.stress_hull_collapseis the odd one out: it fires ONE siege slug down the long axis of a 1296-cell hull and asserts the corridor that left it and that every piece it shed went physical, then RECORDS the collapse frame cost and the debris peak instead of capturing frames, because a one-shot walk cannot fill the baseline window. The UI idiom: a beat NAMES its target (click_named/hover_named/ui_node_centre/ui_node_rectinnova_autopilot::input) so a layout move is survivable and only a rename breaks a run; nothing reaches a widget by triggering its observer or inserting its state component. A target past the fold is SCROLLED to (scroll_lines/scroll_pixelsturn the wheel), so a list taller than its box costs the run nothing, and a target behind a render target is AIMED at by undoing the composite that displays it (nova_os_window_px_showing), since a node laid out by an offscreen camera reports a rect in a space no cursor can be placed in. A driven run that still cannot reach a target says so and states its COVERAGE in the verdict -system_scenario_pickernames any row it gave up on and fails outright below two measurements, since its property is a comparison across selections. The simulation ranges deliberately do the opposite - their subject is the outcome chain, so pixel coordinates would only add layout coupling.screenshots/- the content producers, and the NAME says what a run makes:screenshot_*writes STILLS,loop_*writes VIDEO. One producer captures ONE thing in at most THREE frames, because a long scripted walk cannot hold the same result twice - the fat sets these replace strung a dozen captures onto one script, so a beat that drifted took every frame after it with it. Duplication between producers is the accepted price: several stage the same set, and the scene builders they share live inexamples/screenshots/shared/. The stills, by set: the Drydock drift beauty shot (screenshot_hero_ship), the menu and editor walks (screenshot_menu,screenshot_scenario_picker,screenshot_editor), the section closeups (screenshot_section_frame,screenshot_section_weapons), the Tab interface and the command shell (screenshot_interface,screenshot_command_shell), the Rock hollow combat beats (screenshot_radar_lock,screenshot_contextual_hud,screenshot_combat_lock,screenshot_combat_hud,screenshot_combat_wide,screenshot_hull_juice,screenshot_torpedo_run,screenshot_comms), the spinal lance (screenshot_railgun) and the flight computer around a real well (screenshot_orbit,screenshot_goto_burn,screenshot_flip_burn). Theloop_*producers write only video; recording is not their privilege, though - aloop_start/loop_endpair records from any harnessed example, soscreenshot_railgun,screenshot_editor,wfc_arena,system_torpedo_launch,railgun_wake_bench,stress_hull_collapseandsystem_lock_line_of_sighteach ship a loop beside their stills or their readings. What a run makes is decided byscripts/capture-web-media.sh(loops) andscripts/gen-web-screenshots.py(stills), which name the producer behind every file they package - with one deliberate gap. The three before/after splits are composed BY HAND from a capsule scene of the old release and a loop of the new one, so they enter the media script as IMPORTS: it names the split and the producer of its new half, but never runs that producer and never names thenews-0130-{sections,belt,death}-after.webmfile it writes.loop_sections_compare,loop_belt_compareandloop_death_compareare therefore run by hand, and the module doc on each says how. The posed LINEUPS live here too -screenshot_thruster_gallery(the shipped drive, the proposed shell family and the CC0 candidates in one named row) andscreenshot_damage_levels(the same ship at five damage levels, side by side, which is one comparison and so one producer). Neither registers a key, so a hand-run is a free-fly look at a still row; either would move toplayable/the day it growsgreeble_catalog’s selection layer.scripts/gen-web-screenshots.py --producersprints the list the site actually consumes, so a capture flow never runs off a hand-kept array. Every run of that script first checks the producer columns against the workspace’s[[example]]names and refuses to do anything if one no longer answers, andscripts/capture-web-shots.shstops on an empty list rather than packaging the shipped bytes back over themselves.
When adding a substantial feature, add or extend the range that drives it. When
fixing a bug, WRITE the range that reproduces it first: that is the doctrine in
AGENTS.md, and the invariant roster is what keeps it honest.
Every example is HARNESSED: it drives itself under
NOVA_AUTOPILOT=1, and probe can run any cataloged example -
cargo run --features debug probe run systems (or screenshots, or
playable) runs one category alone, and --all runs the whole catalog.
probe-full.yaml runs a curated 24-example set in five subject shards on a
release and on manual dispatch; a pull request runs a seven-example smoke
subset. Neither CI runtime suite runs the whole catalog.
Each example must reach Playing and exit without panic; every
systems/ range additionally carries panic-on-failure behavior assertions with
completion backstops (a stalled script fails instead of passing vacuously). The
rosters are pinned by the display-free
systems_ranges_assert_their_invariant_roster, so an invariant cannot be
deleted into a still-green run. The screenshots/ and playable/ examples
carry no behavior assertions of their own - they drive the shipped
scenes to capture frames - but every one walks an AutopilotPlugin step
timeline, so a beat that never resolves is an error exit naming that step, and
every one adds nova_probe::NovaProbePlugin, so a probe run grades the walk on
the engine invariants. Disk and catalog
cannot drift: the display-free catalog_matches_disk test
(crates/nova_probe_cli/tests/catalog_drift.rs) fails cargo test --workspace
when a new example misses its [[example]] block. That is the case nothing else
catches - with auto-discovery off, an uncataloged example file does not build at
all and no other tool says so.
The drivers themselves - AutopilotPlugin, the screenshot capture,
the completion protocol, and the full NOVA_* environment contract - live in
the nova_autopilot crate and are documented on
The automation harness. This page only shows the run
recipes; that page is the contract.
Harness runs are SILENT and fly on the DEFAULTS: any harness env
(NOVA_AUTOPILOT, NOVA_CAPTURE) zeroes the audio output via HarnessMute -
Xvfb hides the window but not the speakers, and nobody listens to a scripted run
- and makes
SettingsStorePlugininert, so the run loads nosettings.ron, saves none, and applies no window mode. Both read the same pair throughnova_gameplay::harness_env_active. The volume SETTING is untouched (the mute is an output mask; persistence and the settings menu never see it).NOVA_MUTE=0forces sound through a harness run;NOVA_MUTE=1mutes a normal one, and the game binary’s--muteflag does the same - see the outputs-off pair above.
A HAND-RUN example is not silent and not pinned: it reads the store like the
game does, so with no settings.ron saved it opens in the default window mode,
which is now borderless fullscreen. Save Windowed once from the game’s Graphics
tab (or window windowed in the command shell) and every example follows -
examples read that file, they never write it.
Examples as bug pins
A bug becomes a RANGE (AGENTS.md): reproduce it in examples/systems/
before the fix, and the fix is what turns the range green. A unit/App test
still pins a system-level mechanism, but anything that only manifests in a
composed scene belongs in a range (for example, system_menu_boot runs the
shipped boot flow with the ECS fallback error handler swapped to panic, so
unhandled command errors on those transitions fail CI). A range’s pin is an
autopilot-script assertion (a named step whose on_enter asserts, reached only
once the steps before it have waited on the world - see
system_hull_damage/system_hud_indicators for the style) carrying an
outcome: <slug>
marker on the roster; the release’s curated probe suite runs 24 selected
ranges, and a pull request runs seven of those. Other ranges compile in CI
but need an explicit probe run for runtime proof. Caveat: the handler swap
does NOT catch remove/despawn command warns (they bake in the WARN handler
at queue time).
Launching a scenario from the command line
--scenario <id> on the game binary boots straight into that scenario, past
the main menu - for anyone who would rather not click through the picker, and
for a mod author testing one scenario id in one command:
cargo run -- --scenario tutorial # a base scenario
cargo run -- --scenario my_mod_intro # anything an ENABLED mod registers
cargo run -- --scenario nope # refuses, and lists every id
- The id is matched against the MERGED registry (base plus every enabled mod), so a mod’s scenarios are launchable by id, and so are the menu backdrops - a superset of the rows the Scenarios picker shows. The editor’s own Play range is not in it: it is the open document, not installed content, and the only way to it is Play in the editor.
- An unknown id prints the full id list to stderr and exits non-zero. The check happens once the content merge has run, which is after the window opens: the merged registry does not exist before that.
- The launch is the picker’s own door (
NewGameScenario+GameMode::NewGameGameStates::Playing), so the scenario comes up through the same loader and the same non-blocking load screen as a click on Play.
- It is a launch REQUEST, spent on the first boot. Back to Main Menu re-reads
the content on the way out, and the second pass through
Loadedfinds no request left to open - so the flag opens its scenario once and then hands the session over to the menu, instead of being a door onto itself. - Native only. The wasm bundle has no command line.
Content CLI
content (nova_authoring::cli::main, crates/nova_authoring/src/cli.rs; a
subcommand of the game binary, not a separate bin) authors and validates the
game’s content. Two subcommands, run from the repo root:
cargo run content gen # regenerate the base and story-mod *.content.ron
cargo run content lint # lint the whole content tree
cargo run content lint --target <mod> # lint one mod (dir, id, or `base`)
cargo run content lint --target <mod> --report r.md # + write a per-mod report (md|html)
genserializes the code-built base content into the committedassets/base/**/*.content.ron. The base RON is GENERATED from Rust builders (nova_authoring::generation, backed by privatebase_content) - edit the builder and regenerate, never hand-edit the RON, or thecontent_ron_paritytest goes red.lintruns EVERY content check in one pass (theauditsubcommand was folded in here - balance is a kind of lint):- the identifier + geometry + resource checks the load/publish gates cannot
(dangling
NextScenariotargets, unspawnable filter targets, duplicate ids, scenarios with no terminalOutcome, resource-ref membership, …); - the generated-ship part check - each bundle’s section catalog, with base
and its transitive dependencies, must build one
nova_world_base::ShipPartSnapshot: positive finite part stats, a docking capture angle under 90 degrees, a section id overlaid only along a dependency chain, a usable hull, controller, thruster, cargo intake and docking port, a weapon or thruster that fires straight out of one of its own faces, and no socket on the face a weapon, thruster, cargo intake or docking port fires, opens or docks through. Each fault is reported once per owner: the bundle that authors it, or else each bundle whose catalog raises it; - the combat balance/fairness audit - every combat scenario’s derived sheet,
graded for spawned-dead (ERROR) and close-spawn (WARN) hostiles; a bundle
acknowledges its OWN deliberate imbalances in a
balance_acks.ronbeside its manifest, so the justification travels with the mod (a stale ack that matches no live finding is an ERROR, so every list stays pruned); - the flight-rig input-overlap check - a content
input_mappingsection bound to a key the always-on flight rig also binds (W/Space/RightTrigger burn, autopilot, …) silently double-drives flight and is flagged (WARN). --targetlints a single mod by directory or in-repo id (webmods/<id>,assets/mods/<id>, orbase);--report <path>writes a per-mod document (Markdown, or HTML for a.htmlpath /--format html) that names, for each finding, the file + element + explanation + suggested fix. Exits non-zero on any ERROR. Thecontent_lint_gate,balance_audit_gateandcontent_report_gatetests run these walks in CI.
- the identifier + geometry + resource checks the load/publish gates cannot
(dangling
Web build
WASM via Trunk (Trunk.toml, index.html):
trunk serve # serve the game alone on http://localhost:8080
trunk build --release
For the full site (game at /play/, mod portal at /mods/) with everything
watched, use scripts/serve-web.sh - see Local web preview
below.
.cargo/config.toml sets --cfg=web_sys_unstable_apis for wasm; bevy_rand
uses its wasm_js feature there. Trunk only supports the release profile.
The GitHub Pages deploy (.github/workflows/deploy-page.yaml) builds the
landing site (web/) at the root, the game under /play/, and the generated
mod portal (scripts/gen-portal.py) under /mods/.
The same sources fan out into three build targets that combine into one published site:
flowchart LR src[Sources] src -->|cargo| native[Native game] src -->|web build| landing[Landing + wiki] src -->|trunk| wasm[Bevy WASM game] landing --> pages[GitHub Pages] wasm --> pages pages --> root["/ (landing)"] pages --> play["/play/ (game)"]
Local web preview
The published site is three builds stitched together - the content site at /,
the WASM game at /play/, the generated mod portal at /mods/. Serving only
one of them locally is what makes Play fall back to the landing page and the
in-game Explore tab come up empty. Two scripts cover the two things you actually
want:
nix develop -c scripts/serve-web.sh # live dev: all three, watched
nix develop -c scripts/preview-web.sh # one-shot static build of the deploy
serve-web.sh starts all three servers and proxies the other two onto the
site’s origin, so a single URL has the deployed shape:
flowchart LR you([Browser]) you -->|":UI_PORT/"| site["webpack dev server<br/>watches web/src"] site -->|proxy /play| game["trunk serve<br/>watches crates, src, assets"] site -->|proxy /mods| mods["serve-mods.sh<br/>watches webmods/"]
Everything rebuilds on save: edit a wiki page and the tab reloads, edit a crate
and Trunk rebuilds the wasm, edit a mod and the portal is regenerated in place.
--release switches the game to an optimized build. Ctrl-C stops all three.
Each server takes a random free port in 7000-7999, so several worktrees can serve at once - the banner prints the URLs. Pin any of them, or point the site at servers you started yourself:
| Variable | Read by | Effect |
|---|---|---|
NOVA_UI_PORT | web/webpack.config.js | Fixes the site’s port. |
NOVA_GAME_PORT | scripts/serve-web.sh | Fixes the game’s port (exported as TRUNK_SERVE_PORT). |
NOVA_MODS_PORT | scripts/serve-mods.sh | Fixes the portal’s port. |
GAME_DEV_URL | web/webpack.config.js | Where /play is proxied. Default http://localhost:8080 (Trunk’s own default). |
MODS_DEV_URL | web/webpack.config.js | Where /mods is proxied. Default http://localhost:9000. |
Two things are worth knowing before you go off-script:
- Trunk needs explicit watch paths here. Its default (“the build target’s
parent folder”, i.e. the repo root) never fires in this repo, so a bare
trunk servekeeps serving the first build no matter what you edit.serve-web.shpasses--watchfor each real input (crates,src,assets,credits,build,index.html,Cargo.toml,Cargo.lock). - The portal must be same-origin with the game. The wasm build derives its
portal base from
window.location, so under/play/it fetches<origin>/mods. That is why the site server proxies/mods, and why a cross-origin?portal=override fails on CORS. See Publish a mod.
preview-web.sh is the other half: no dev servers and no proxies, just
trunk build + npm run build + gen-portal.py assembled into web/dist and
served statically on :8090. It does not watch anything, but it is the only
local check of the real deploy layout - run it before a release.
Regenerating the web screenshots
The site’s .figure blocks ship as placeholders; the real screenshots are
captured in-engine and packaged into web/src/assets/ by
scripts/gen-web-screenshots.py. Each figure auto-upgrades to its image at
runtime once the asset exists (progressive enhancement in web/src/site.ts), so
no HTML edit is needed - just drop the file in.
Capture and package in one command:
nix develop --command scripts/capture-web-shots.sh # every producer
nix develop --command scripts/capture-web-shots.sh screenshot_railgun # or a few
It stages into target/shots, runs each producer under Xvfb, and then packages
with scripts/gen-web-screenshots.py. It is the stills half of a pair:
scripts/capture-web-media.sh does the same for the webm loops, and
scripts/capture-lesson-media.sh the same again for the training handbook’s
demonstrations - the lesson_* producers, each writing one lesson’s still or
4x5 sprite sheet into assets/base/training/<lesson>.webp. That one ships
WEBP, not PNG: the Lessons pane draws a demonstration about 900 logical pixels
wide, so a sheet carries 960x540 cells (3840x2700 in all), and at that size a
lossless sheet is about 1.6 MB against about 150 KB encoded. Every lesson the
base handbook authors has a producer; one authored WITHOUT one keeps the
placeholder scripts/gen-lesson-media.py draws - lossless WebP, at the lower
resolution it is drawn at - and that generator REFUSES to redraw over captured
footage, so the two can be run in either order. Both point
NOVA_MODDING_CACHE_ROOT and NOVA_CONFIG_ROOT at the capture sandbox and stamp
a base-only mod set BEFORE EACH producer, so neither an installed mod nor a
producer that enables one on purpose (screenshot_scenario_picker does) can
dress the next shot.
The capture examples run headless under NOVA_AUTOPILOT: each is one autopilot
script whose steps pose the camera and shoot, and NOVA_CAPTURE is what makes
those shot steps write 1920x1080 PNGs rather than drive straight through. The Python step validates
each shot is 16:9, copies it in, builds the composite shots a single capture
cannot make (e.g. devlog5-radar-stance-slots, two lock stances side by side)
with a stdlib PNG codec, generates the section icons, and reports which shots
have no capture example yet. Commit the resulting PNGs (they are content, like
banner.png). Run python3 scripts/gen-web-screenshots.py --self-test to check
the PNG codec (decode/resize/compose) and the report’s classification rules in
isolation.
What is still missing
python3 scripts/gen-web-screenshots.py --report
Scans web/src/** for referenced assets/<name> images, diffs them against the
manifest and the shipped assets, and prints each gap with an owner class:
| Class | Meaning |
|---|---|
capturable | A game render a cataloged example can capture. |
manual | Authored art (post-card thumbnails, icons, diagrams) - no automation produces it. |
historical | A figure for an older shipped version; the current build can only approximate it. |
Wrong-shaped and unreadable assets, assets the site never references, and staged PNGs the manifest does not declare print the same way. The report is ADVISORY: it copies nothing and always exits 0, so it is a worklist (for the owner: what art to draw; for automation: what a producer could capture), never a gate.
It closes with the GAME’s half of the same worklist: every Scenarios-picker
thumbnail still on generated placeholder art, classed manual.
Scenario picker thumbnails
Every picker-visible scenario shows its own image in the details pane. Real per-scenario art is authored, not captured, so until it exists each scenario carries a deterministic placeholder - a 320x180 phosphor plate of its own title:
python3 scripts/gen-scenario-thumbnails.py # write every PNG
python3 scripts/gen-scenario-thumbnails.py --check # verify, write nothing
The PNG lands in the OWNING mod’s tree (assets/base/thumbnails/<id>.png,
webmods/<mod>/thumbnails/<id>.png) and is referenced as
self://thumbnails/<id>.png - never dep:// another mod’s art. Drop real art
at the same path and nothing else changes; the report stops listing it, because
the file no longer matches a fresh render. A new scenario adds one row to
SCENARIOS in the script and one entry to its bundle’s resources; a scenario
with no art of its own is what the coverage report lists.
Greeble meshes
The decorative fixtures a hull skin bolts onto its plates - vents, ribbing,
blisters, masts - are GENERATED, not modelled. scripts/gen-greebles.py builds
one .glb per JSON recipe in scripts/greeble-recipes/ into
assets/base/gltf/greebles/, all committed:
python3 scripts/gen-greebles.py # write every .glb
python3 scripts/gen-greebles.py --check # verify, write nothing
python3 scripts/gen-greebles.py --self-test # internal checks, no I/O
A recipe is a palette plus a list of primitives - box, cylinder, taper,
ribs, disc - each with a size, an at offset and an optional rotate.
Solids compose by overlap, not by CSG. Adding a piece is a new recipe file plus
one entry in assets/base/base.bundle.ron’s resources; no code changes.
Pieces are authored in the PLATE’s frame (the unit cell, out along +Y, y = 0
the mounting face) and the generator refuses anything behind that plane, wider
than half a cell, taller than its declared budget, or over 200 triangles. The
build is byte-deterministic, which is what --check gates: generated art that
churns turns every unrelated diff into a binary one.
The three mesh scripts (gen-greebles.py, cut-obj-into-hulls.py,
cut-obj-into-parts.py) share ONE hand-rolled, stdlib-only glTF writer,
scripts/nova_glb.py. Prove a change to it kept the committed art intact by
re-cutting a ship and diffing against webmods/the-ledger/gltf/parts/<ship>/,
which is byte-reproducible from its recipe.
Eyeballing the site
npm run ci proves the bundle compiles and the theme tokens are in sync; it
proves nothing about how a page LOOKS. For any styling, layout or readability
change, capture the pages and look at them:
nix develop -c scripts/shoot-web-pages.sh target/web-shots
It builds web/, serves web/dist on a free port and drives headless chromium
over the six page kinds (landing, news index, a news post, tutorial, wiki index,
a wiki page with code + a table + mermaid) at desktop and mobile widths,
writing <kind>-<width>.png plus a manifest.txt naming the commit. For a
before/after, run it once per commit into two dirs and compare the matching
pairs at identical crop and scale - a comparison at two different zooms shows
you the resize, not the change.
The theme is shared with the game
web/src/style.css and 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.versionin rootCargo.toml; crates inherit it. nova_info::APP_VERSIONcomes from theAPP_VERSIONenv var viabuild.rs. A--features debugbuild appends the short commit hash (0.12.0+9da8b055, or+unknownoutside a checkout), so the status bar names the revision. The build script tracks.git/HEADand its ref, so a new commit restamps it.- Packaging assets (icons, installer, .app) live under
build/.
The nix package
The flake packages the game as well as the development shell, so a player needs neither a checkout nor a toolchain:
nix run github:alexjercan/nova-protocol # build it and play
nix profile install github:alexjercan/nova-protocol
nix build .#default # ./result/bin/nova-protocol
Three outputs, and which one you want depends on what you are doing:
| output | what it is |
|---|---|
packages.default | the wrapped game - the one to install |
packages.nova-protocol | the same derivation, under its own name |
packages.nova-protocol-unwrapped | the bare cargo binary |
The unwrapped binary finds no assets and opens no window on its own. It is split out so that editing the desktop entry, the icon or the asset wiring costs a second instead of a fat-LTO relink of the whole Bevy graph. The wrapper is what makes it a game:
assets/andcredits/are installed undershare/nova-protocol, andBEVY_ASSET_ROOTpoints bevy’s reader at them. Without it the reader falls back to the directory the executable sits in, which in the store holds the binary and nothing else. It is set as a DEFAULT, so a modder can still point a packaged build at a working tree.- The libraries Bevy opens with
dlopen- vulkan, wayland, X11, xkbcommon, alsa, udev - go onLD_LIBRARY_PATH. The linker never records them, so a packaged binary finds none of them without this. It is the same list the development shell exports, shared inflake.nixrather than written twice.
The package builds with --profile dist, the profile
.github/workflows/release.yaml ships, so nix run gets the binary a player
gets. Fat LTO and one codegen unit: budget half an hour for a cold build.
webmods/ is NOT in the package. Those are the portal’s development fixtures,
served by scripts/serve-mods.sh; the mods:// source reads installed mods
out of the player’s data directory (~/.local/share/nova-protocol/mods), which
no store path can hold.
The package’s toolchain is rust-project.toolchain, set to the same
rustNightly value the development shell uses. Left alone, rust-flake resolves
its own from rust-toolchain.toml, and the package and the shell could drift
apart on a nix flake update - so there is still ONE pin in flake.nix, the
one the comment beside it names.
The desktop entry
nix profile install also installs
share/applications/nova-protocol.desktop and the icon into the hicolor
theme, so rofi -show drun, wofi, and the GTK and KDE menus list “Nova
Protocol” with its mark. ~/.nix-profile/share is already on XDG_DATA_DIRS,
so nothing else has to be wired; nix run and nix build do not install it,
because neither adds anything to a profile.
The icon is rendered from web/src/favicon.svg, the site’s brand mark, into
the eight hicolor sizes plus the scalable SVG. A launcher resolves
Icon=nova-protocol by name against the theme, so the sizes have to exist as
files: an icon theme with no scalable support finds nothing otherwise. The
art under build/ is still bevy_game_template’s placeholder bird and is
deliberately not used.
Cutting a release
Pushing a tag v[0-9]+.[0-9]+.[0-9]+* triggers release-flow
(.github/workflows/release.yaml). Steps, on master:
- Check the documentation surfaces are current (see
Keeping docs in sync): this book builds clean
(
mdbook build) and the pages the cycle’s changes touched are updated. - Bump
workspace.package.versionin rootCargo.toml. - Refresh
Cargo.lock:cargo metadata --format-version 1 >/dev/null. - Update
CHANGELOG.md(Keep a Changelog, one concise line per entry): promote[Unreleased]to[<version>] - <YYYY-MM-DD>, leave a fresh empty## [Unreleased]on top, merge any duplicate section headings that grew during the cycle, and update the compare links at the bottom (repoint[unreleased], add the new[<version>]line). - Commit exactly those three files:
git add Cargo.toml Cargo.lock CHANGELOG.md && git commit -m "chore(release): vX.Y.Z". git tag vX.Y.Z(CI reads the tag for the release name).git push origin master && git push origin vX.Y.Z.- Watch the run (
gh run watch), then check the GitHub release page and consider adding summarized release notes (gh release edit vX.Y.Z --notes-file ...). - Write or expand the release News post (see “Writing the release news post”
below) and land it in
web/; sync any wiki pages the cycle changed (see Keeping docs in sync).
The workflow uploads four assets to a release named after the tag: macOS
universal .dmg, Linux .tar.gz, Windows .zip, and a wasm-opt’d web zip.
The macOS job writes CFBundleShortVersionString and CFBundleVersion from the
tag, then refuses to create the DMG unless Info.plist declares the copied
nova-protocol executable. The workflow can also be re-run via
workflow_dispatch with a version input.
Deploying a release to itch.io
The manually dispatched deploy-itch workflow
(.github/workflows/deploy-itch.yaml) uploads an existing GitHub release to
alexjercan/nova-protocol. It never rebuilds the game. The job downloads the
four assets named above, and scripts/prepare-itch-upload.sh verifies and
extracts them into the stable Butler channels windows, osx, linux, and
html5. On macOS, hdiutil mounts the release DMG and ditto preserves the
app bundle metadata. On Linux, the Nix development shell supplies 7-Zip to
extract the HFS+ image; the script restores the declared app executable’s mode.
Windows and web ZIPs use ordinary unzip. The macOS preparation checks that
CFBundleExecutable names a real executable, and the web preparation requires
index.html and the wasm file at the upload root. Production CI stays on
macOS and uses the native path; the Linux fallback makes local preparation and
preflight available without Apple tools.
Configure the protected itch-production GitHub Environment before the first
run:
- secret
BUTLER_API_KEY: an itch.io API key for thealexjercanaccount; - variable
ITCH_TARGET:alexjercan/nova-protocol; - variable
BUTLER_VERSION: the exact Butler release to install; - variables
BUTLER_SHA256_DARWIN_AMD64andBUTLER_SHA256_DARWIN_ARM64: the SHA-256 sums of that release’s official macOS archives.
Use the Butler download documented by itch.io to choose the version, download
each macOS architecture archive from
https://broth.itch.zone/butler/darwin-<arch>/<version>/archive/default, and
record its shasum -a 256 output in the matching Environment variable. The
workflow refuses a floating Butler version or an archive whose checksum does
not match.
After the GitHub release and Pages deployment are complete, keep the itch.io page in Draft and run:
gh workflow run deploy-itch.yaml -f tag=vX.Y.Z
gh run watch
The protected Environment supplies the explicit approval boundary. The run
records release checksums and the prepared file inventory as an Actions
artifact. Confirm itch.io detected each platform, mark html5 playable in the
browser if needed, and launch all four channels on clean target systems. Only
then change the page from Draft to Public. A later Butler push updates a public
channel immediately, so a failed or mismatched channel blocks another deploy.
Writing the release news post
Every release cycle gets one News post on the site (/news/, markdown under
web/src/news/). News is the merged devlog + release notes: one post per
FEATURE release (v0.X.0). Patch releases do NOT get
their own post - they fold into the parent feature post’s ## Point releases
section (v0.5.0’s post covers v0.5.1 and v0.5.2). The terse per-version
list stays in CHANGELOG.md; source the post’s content from the cycle’s
CHANGELOG.md sections.
A News post follows the spirit of Factorio’s Friday Facts: a narrative lead,
then a handful of feature-by-feature ## sections written candidly (the
reasoning, the dead-ends, the piece you are proudest of), leaning on screenshots,
and - where a devlog video exists - an optional ## Watch the devlog companion
near the top (the written highlights must stand on their own; the video is an
extra). Do not just restate the terse CHANGELOG.md.
Adding a post touches three places (mirror an existing post such as
web/src/news/0.5.0.md):
- Write the post at
web/src/news/<version>.md(e.g.0.6.0.md). The page shell (newsPostShellinweb/markdown.js) renders the H1, the<date> // v<version>meta line, and the footer (the Discussions prompt plus theCHANGELOG.mdpointer and “All news” link), so the markdown is just the body: the H1 (# vX.Y.0 - <title>), the lead, the##sections,.figureplaceholder blocks for screenshots to capture later, an optional.video-embedcompanion, a.callout.callout--breakingblock for any format break, and a closing## Point releasessection for the cycle’s patches. Do not add a footer or aCHANGELOG.mdlink yourself - the shell adds them. - Register it in
web/webpack.config.js: add an entry toNEWS_POSTS(newest-first) withslug/version/date/description. The plugin list and thehistoryApiFallbackrewrite both derive fromNEWS_POSTS, so no other wiring is needed. - Add a
.post-cardtoweb/src/news.htmlat the top of.post-grid(newest-first): a media thumbnail plus the date/version, title, and one-line excerpt. For the thumbnail, use the YouTube thumbnail (https://img.youtube.com/vi/<id>/hqdefault.jpg) if the release has a video, otherwise the.post-card__phplaceholder namingassets/thumb-news-<version>.png. - Rebuild and check it:
cd web && npm run ci(format check, lint, test, build).
Comic deployment
npm --prefix web run build:story builds only released comics into
web/dist-story/story/. Its local scripts, images, favicon, and font assets
stay under /story/. Shared theme/header sources are compiled into that
snapshot; unrelated website pages are neither built nor published. The shared
Google Fonts import remains external. Normal serving still uses the complete
website and labelled drafts; there is no second development server.
Pushing a comic-* tag starts deploy-comic. A manual run accepts an existing
comic tag for a retry or rollback. The workflow resolves the tag to a commit,
builds once, and passes the artifact and exact commit to its publisher. The tag
names a library snapshot, not a filter: publication metadata selects episodes.
Game vX.Y.Z tags and version numbers remain separate. The comic authoring
README owns the publishing procedure and first-rollout checklist.
The full Pages workflow uses build:site, which does not execute or emit comics.
Both publishers use scripts/deploy-pages.py. It fetches the latest gh-pages
under a shared github-pages-publish job lock, builds a temporary Git index and
working tree, and commits a scoped merge without changing the source checkout’s
index. A comic deployment replaces only story/; a site deployment preserves
it. Both preserve CNAME and ensure .nojekyll. Non-fast-forward pushes fail.
Pages hosting must use the gh-pages branch root. Both jobs explicitly request
and wait for a Pages API build with a write-scoped Pages token.
Bootstrap the new comic directory before a site-only deployment can remove old
root-level reader files. The guard requires story/release.json, which records
the comic tag and source commit. Use the current site’s workflow, not a workflow
from an older game tag that predates the ownership split. Serial publishing is
not a FIFO release queue; wait for one deployment before starting another.
npm --prefix web run test:deploy checks isolated builds with released/draft
fixtures and local bare-Git publishing with mocked HTTP. It never publishes to
GitHub. Normal npm run build still builds the complete released-only website
for checks; it is not the input to either scoped publisher.
Writing a story comic
web/src/comics/ is the single home for maintained comic sources. Each season
has comic.json; each episode has episode.json, an ordered episode.ts, one
TS file per page under pages/, and Python panel drawings under art/. The
directory names provide /story/season-1/episode-1/, with page ids in the
fragment. Shared faces, ships, and colors remain in scripts/nova_illustration/.
web/src/comics/README.md owns the DSL and build contracts. Public exporters
must not import task files.
/story/ lists seasons only, in reading order. Each season has one illustrated
row with its title, summary, selected episode count, and labelled local draft
count. Click it to open /story/<season>/, which lists that season’s episodes
in authored order. Each episode has one clickable row and one thumbnail;
drafts show how many pages are illustrated. There are no duplicate reading
shortcuts or repeated season cover above the episodes.
The season page returns to the library through Story. The reader returns to its episode list through the season title, such as Season 1; Pages opens its page chooser. The phosphor shell surrounds full-color art.
Run npm --prefix web run serve and open /story/ at the printed loopback URL.
Normal local development includes labelled drafts. Source edits regenerate pages
and reload the same page fragment. It uses web/.cache/story/development/site,
not web/dist, and does not serve stale files from a prior disk build.
scripts/serve-web.sh starts this same server with the game and portal.
npm --prefix web run build always selects released episodes, even with webpack
in development mode. Serving with --mode production also excludes drafts.
Selection happens before generation: an excluded episode’s code never runs,
and none of its routes, assets, dialogue, or script enters the output. Only
referenced assets are emitted; comic directories are not broad copy/import
inputs. With no released episodes, the public archive has a plain empty state.
Every metadata field is required. comic.json gives the unique positive season
sequence, title, summary, and ordered episode directory ids. episode.json
gives title, summary, cover scene id and alt text, TS entry filename, explicit
Python art-module paths, complete pageCount, and publication (draft or
released). Unknown fields, missing sources,
unregistered directories, duplicate ids, and invalid generated data fail.
Released episodes must form a prefix of reading order and have exactly their
authored page count. A season can contain released episodes and the next draft.
Publishing changes metadata after approval; sources stay in place. Comic release
remains independent of campaign implementation.
The TS page language owns action, dialogue, screen labels, narrative location
cards, and page layout. Dialogue uses stable ids; balloon placement is keyed
separately. Unillustrated pages remain TS scripts without fake art references.
The shared build engine derives reader definitions, transcripts, and a reading
copy named SCRIPT.md in the generated cache. That Markdown is not another
maintained source. Unillustrated story text never enters a reader manifest.
Python art modules register named Scene functions. The common builder exports
only referenced standalone panel SVGs; it neither draws nor strips dialogue.
Blank named text slots preserve labels’ painter order under hands and equipment.
Raw SVG is checked before export, and the browser checks it again on import.
comic-panels.ts composes the page frame, panels, labels, cards, and measured
balloons. comic-renderer.ts owns the DOM; ComicPlayer in story-reader.ts
owns fitted playback, contents, deep links, and input. New pages update
automatically. Route changes and build-engine JS changes need a server restart.
Open image exports the displayed full page. The compact Panels and Transcript toolbar buttons open scrollable modals without taking space from the page. Panels offers each raw art SVG and its lettered export. Close or Escape restores focus to the opening button. These exports share the reader’s measured layout. Automatic wrapping uses actual font metrics; explicit word-count breaks retain accepted lettering. The local Art only control hides speech. Automated browser checks inspect measured layout without a reader control or diagnostic overlay. Face checks use contour bounds, not hair boxes, and do not replace visual review. Full-color pages have no CRT tint, scanlines, or shade overlay. Older typed HUD helpers remain internal renderer building blocks, not a demonstration or a second story authoring workflow.
Run cd web && npm run ci, then inspect the season library, each season’s episode
list, and reader routes at desktop and narrow widths, including a project URL
prefix. Check browser Back and the labelled return links at both levels.
The reader displays one page. Buttons, contents, arrow/Page keys, wheel, and a
single-finger vertical swipe change pages. Modified zoom gestures are not page
turns. Open image uses the browser’s full-size image view and native zoom for the
complete composition. Transcript exposes the selected page’s derived text in
its modal; scrolling it does not turn the underlying page. Contents hidden from view are also hidden from keyboard
navigation. Mobile image fitting is an overview, not a claim that small
landscape lettering is readable without zoom or text.
Contributing a change
The everyday loop for landing a change:
-
Branch off
master. Work items are tracked astasks/markdown (see Task tracking below); check the backlog first. -
Build and format:
cargo check && cargo fmtbefore you commit. Do NOT runcargo testorcargo clippylocally unless asked - they are slow and CI is the source of truth; when you skip them, say so. -
Drive it with an example. For a substantial feature, add or extend the
examples/example that exercises it, with a harnessed autopilot assertion (see Examples) - this repo prefers a runnable example over an isolated unit test. -
Open a PR. CI (
.github/workflows/ci.yaml) runs on pull requests and pushes tomaster, so the integrated tree is revalidated after merge:cargo fmt --check,cargo clippy --workspace --all-targets --features debug -- -D warnings,cargo test --workspace --features debug, then a windowed seven-exampleprobe run --correctness-onlysmoke under Xvfb/lavapipe plus thenova_autopilotexample test under Xvfb. Three more jobs run in parallel with that one: a default-featurescargo check --workspace --all-targetsunderRUSTFLAGS=-D warnings, a wasm32cargo clippy --workspace --exclude nova_probe_cli(the host harness has no meaning in a browser), and a dependency-license gate. Those two exist to catch dead code and unused imports that only appear withdebugoff or on wasm - neither configuration is otherwise built. All of it must be green to merge.The curated release set lives in
.github/workflows/probe-full.yaml, a five-shard suite with no push or PR trigger.release.yamlcalls it and every platform build waits on it, so a tag cannot ship past a red selected example; run it by hand from any ref withworkflow_dispatch. Both runtime lists are named, soscripts/check-probe-suites.py(a step in thecheckjob) rejects unknown and duplicate names, empty sets, and smoke examples outside the full shards. It does not require every[[example]]to run in CI; the independent catalog drift test checks disk against Cargo.toml, and the default all-target check compiles the remaining examples.The wasm job is CLIPPY rather than
check, and it pointsCLIPPY_CONF_DIRatci/wasm-clippy/, whoseclippy.tomlbans the std APIs that COMPILE for wasm32 and then panic in the browser - a class of bug nocargo checkon that target can see. Those bans are correct only there: nativelybevy::platform::timere-exports std’s clock, so the same list would flag correct code in the main job. That is why the config lives outside the repository root, and why there is no rootclippy.toml.
House style is in AGENTS.md
at the repo root - Rust, Bevy, Nova, comments, documentation, changelog and web,
each a section. Commit messages are plain and use ASCII punctuation only.
Releases are a separate, tagged flow (see Cutting a release).
Task tracking
Work items live as markdown under tasks/ (managed with the tatr CLI), so
they are versioned alongside the code. Check the backlog before starting and
close tasks when done. Each task has its own folder holding its TASK.md plus
any task-scoped records (SPIKE.md, REVIEW.md, RETRO.md, NOTES.md).
Multi-task plans are tatr tasks too - a release plan is a task with the strand
breakdown in its body (or a release/meta tracker task linking the per-strand
tasks). docs/ is the source of this book, not scratch: transient working
files live outside the repo, and task-scoped records live in tasks/<id>/.