Add a ship section
Ship-section kinds are a CLOSED enum. There is no data-driven registry for
them: SectionKind (crates/nova_ship/src/sections/base_section.rs) is a
Rust enum, every match on it is exhaustive, and the compiler will not let you
land a new variant until every site handles it. Adding a kind is a fixed
sequence of ~10 edits across nova_ship, nova_gameplay, nova_scenario,
nova_editor, nova_os_ui, and nova_authoring,
ending at a runnable example.
Why it is closed
Section kinds are first-class engine concepts, not content. Each kind carries
its own typed config, its own spawn bundle, its own behavior systems, and its
own arm in every exhaustive match over section kinds – none of which a RON
author could supply. What IS data-extensible is the section CATALOG: a SectionConfig
(base stats + one SectionKind instance) is authored in RON and loaded at
runtime (see the Ship sections reference and
modding). New KINDS are code; new INSTANCES of an existing
kind are data. This guide is about the former.
The closed enum is a feature: it means “did I wire the new kind into the ship computer / the editor / spawning?” is a compile error, not a silent gap.
flowchart LR
A[config module] --> B[SectionKind enum]
B --> C[SectionClass label]
C --> D[section plugin]
D --> E[spawn arm]
E --> F[editor place + gallery]
F --> G[asset prototype]
G --> H[example]
Checklist
Do these in order. Steps 2-7 will not compile until the ones before them exist, and the exhaustive matches force you through 3-7 once the enum has the variant.
Replace <kind> / <Kind> below with your section name (e.g. shield /
Shield).
-
New config module. Create
crates/nova_ship/src/sections/<kind>_section.rs, modelled onhull_section.rs(simplest) orturret_section/(a multi-file module: behavior + FixedUpdate systems). It defines: a<Kind>SectionConfigstruct, a<kind>_sectionbundle fn, a<Kind>SectionMarkercomponent, a<Kind>SectionPlugin, and apreludere-exporting them. The bundle MUST insert the marker and theSectionClassfor the kind:#![allow(unused)] fn main() { pub fn shield_section(config: ShieldSectionConfig) -> impl Bundle { ( ShieldSectionMarker, SectionClass::Shield, // ... kind-specific render/behavior components ) } }Then register the module in
crates/nova_ship/src/sections/mod.rs: addpub mod <kind>_section;and re-export<kind>_section::prelude::*in the moduleprelude. -
Add the enum variant. In
crates/nova_ship/src/sections/base_section.rs, add the variant toSectionKind(grep forenum SectionKind):#![allow(unused)] fn main() { pub enum SectionKind { Hull(HullSectionConfig), Thruster(ThrusterSectionConfig), Controller(ControllerSectionConfig), Turret(TurretSectionConfig), Torpedo(TorpedoSectionConfig), Shield(ShieldSectionConfig), } } -
Section class. Add the variant to
SectionClassincrates/nova_gameplay/src/damage.rs(grep forenum SectionClass). It is a LABEL, not a damage key – there is no resistance table, so there is nothing else to fill in on the damage NUMBER. How much a section takes is itshealth; how far a round gets through it is the travel rule, which readsHealth.maxand never the class. What the section LOOKS like as it is damaged is a separate, authored decision - step 8.The NOVA OS ship app labels sections by this enum: add an arm to the exhaustive matches
code_prefix,kind_glyph,kind_descriptionandkind_indexincrates/nova_os_ui/src/ship/sections.rs, and tosection_kind_labelincrates/nova_os_ui/src/terminal/content.rs. -
Wire the section plugin. In
crates/nova_ship/src/sections/mod.rs, add your plugin to theadd_plugins((...))tuple inSpaceshipSectionPlugin::build(grep forimpl Plugin for SpaceshipSectionPlugin), passing therenderflag like the others:#![allow(unused)] fn main() { <kind>_section::ShieldSectionPlugin { render: self.render, }, } -
Spawn arm. In
crates/nova_scenario/src/objects/spaceship.rs, add a match arm toinsert_spaceship_sections(grep for it, then itsmatch &config.kind). At minimum insert the kind bundle; add input-binding / infinite-ammo handling only if your kind needs it (see theTurret/Thrusterarms for those patterns):#![allow(unused)] fn main() { SectionKind::Shield(shield_config) => { section_entity.insert(shield_section(shield_config.clone())); } }This is the production spawn path; see the Scenario engine for how the spaceship object and its section observer fit together.
-
Editor placement arms. Two exhaustive matches, in two files.
default_binds_for(crates/nova_editor/src/placement.rs) gives the kind its default key/pad binding, orvec![]if it takes none - modelHullfor unbindable,ThrusterorTurretfor bindable.insert_preview_section(crates/nova_editor/src/preview.rs) inserts the<kind>_section(...)bundle beside the sharedpreview_section(...)recipe, plus the kind’s input-binding component if it has one.Nothing here computes a placement POSE. The editor mates link points (
snap_placement,nova_ship::sections::link_points), so orientation comes from the two sockets, not from the kind - which is what lets a part mate the same way up on any socket. Recording the placed section intoplayer_config.sectionsis likewise generic (register_preview_section), so the arms only insert. -
Parts gallery category + readouts. In
crates/nova_editor/src/gallery/catalog.rs, add aGalleryCategoryvariant (with itsROWentry,label()andaccepts()arms), then arms tokind_label()andbehaviour(). All of them matchSectionKindexhaustively, so the compiler walks you through it:#![allow(unused)] fn main() { // accepts Self::Shields => matches!(kind, SectionKind::Shield(_)), // kind_label - the tile's category line SectionKind::Shield(_) => "shields", // behaviour - the two or three numbers a builder picks the part BY SectionKind::Shield(shield) => { vec![("capacity".to_string(), format!("{:.0}", shield.capacity))] } } -
Asset prototype. In
crates/nova_authoring/src/base_content/sections/standard.rs, add aSectionConfigtostandard_section_prototypes()so the catalog ships a ready-to-place instance. Give it a stable snake_caseid(this is whatsections.get_section("...")and RON authors reference). The id is a runtime STRING that nothing type-checks: keep it a literal beside the builder, and promote it to aconstincrates/nova_ship/src/sections/catalog_ids.rsonly when a crate that cannot reachnova_authoringhas to name it.#![allow(unused)] fn main() { SectionConfig { base: BaseSectionConfig { id: "basic_shield_section".to_string(), name: "Basic Shield Section".to_string(), description: "A basic shield section for spaceships.".to_string(), health: 100.0, damage_effects: DamageEffects(vec![DamageEffect::Cracks, DamageEffect::Sparks]), ..default() }, kind: SectionKind::Shield(ShieldSectionConfig { /* ... */ }), }, }damage_effectsis a real design decision per kind, not boilerplate. The shipped rule: a hull authors nothing (defaulting to[Cracks]), anything carrying machinery addsSparks, and a thruster addsPlumeon top. Pick by what the part IS - a turret is all function and fails by sparking, a hull has nothing but material to lose. The rule the vocabulary is kept honest by: no section loses geometry, so every effect is a material or a particle. See Ship sections internals.If your config needs a render-mesh
AssetRef, add a field toBaseContentAssetsand itsfrom_paths()incrates/nova_authoring/src/base_content/assets.rs.The builders do not feed the game directly: regenerate the committed RON with
cargo run content genand commitassets/base/sections/base.content.ronwith the code change - thecontent_ron_paritytest fails on drift. -
Example. Add
examples/systems/<what it proves>.rs, modelled on the existing per-section ranges (system_attitude_hold.rsandsystem_thrust_and_plume.rsare the most compact), plus its[[example]]block in the root Cargo.toml (auto-discovery is off; the catalog is the source of truth) -catalog_matches_diskincrates/nova_probe_cli/tests/catalog_drift.rsfails until disk and catalog agree, andsystems_ranges_assert_their_invariant_rosterbeside it fails until the new range has a named invariant roster. The example builds a minimalScenarioConfig(a controller + your section), triggersLoadScenario(...), and under--features debugdrives an autopilot probe that asserts the kind’s behavior end to end. Run it:NOVA_AUTOPILOT=1 cargo run --example <what it proves> --features debug
Done
The compiler is your checklist enforcer for 2-7: if it builds, every exhaustive match handles the new kind. Steps 1, 8, and 9 are the ones with no compile-time backstop – the module wiring, the catalog instance, and the runnable proof – so double-check those by hand.
Find it in the code
- The enum and base config:
SectionKind,BaseSectionConfig-crates/nova_ship/src/sections/base_section.rs; model moduleshull_section.rs(minimal) andturret_section/beside it. - Class label:
SectionClass-crates/nova_gameplay/src/damage.rs; NOVA OS label matches -crates/nova_os_ui/src/ship/sections.rs. - Spawn arm:
insert_spaceship_sections-crates/nova_scenario/src/objects/spaceship.rs. - Editor arms:
default_binds_for-crates/nova_editor/src/placement.rs;insert_preview_section-crates/nova_editor/src/preview.rs;GalleryCategory-crates/nova_editor/src/gallery/catalog.rs. - Cross-crate prototype ids:
crates/nova_ship/src/sections/catalog_ids.rs. - Prototypes:
standard_section_prototypes-crates/nova_authoring/src/base_content/sections/standard.rs. - API detail:
cargo doc --open -p nova_ship.