Create / Modding reference / Scenario objects

Scenario objects

Everything a scenario can place in the world. An object is spawned by SpawnScenarioObject (or in bulk by ScatterObjects): a shared base block plus a kind that picks one of the SIX kinds below. Every object gets the base's id, name and pose, is scenario-scoped (teardown removes it), and carries a type name the type_name filters match:

kind type name body what it is
Anchor "anchor" static invisible authored gravity well (framing/orbit target)
Asteroid "asteroid" dynamic destructible rock, optional gravity well
Spaceship "spaceship" dynamic a multi-section ship, player- or AI-flown
Beacon "beacon" static lockable nav marker with a HUD chip
SalvageCrate "salvage_crate" static fly-through pickup
Light "light" static the scene's own lighting

(Trigger AREAS are spawned by the CreateScenarioArea action rather than as an object kind - and beacons and crates can be their own areas, below.)

Anchor

An invisible authored point that publishes a gravity well with an AUTHORED radius: no mesh, no collider, and no geometric extent for AI obstacle avoidance to steer around. Use it where a contract needs a position plus well geometry but the scene does not want a rock there - an orbit directive's target, or a real gravity source with no body. Because the radius is authored (an asteroid's is derived from its generated mesh), everything reading the well sees the same geometry on every load.

field type default meaning
body_radius number required the well's published body radius, world units
mass Option number None the gravity parameter mu (u^3/s^2), same unit as an asteroid's mass. None = a zero-strength well: it frames and anchors but never pulls
SpawnScenarioObject((
    base: (id: "patrol_anchor", name: "Patrol Anchor", position: (0.0, 0.0, 0.0), rotation: (0.0, 0.0, 0.0, 1.0)),
    kind: Anchor((
        body_radius: 80.0,
        mass: Some(30000.0),
    )),
)),

An anchor is indestructible (there is nothing to hit) and static; it never fires destruction events.

Asteroid

A noise-generated destructible rock. radius drives the mesh, collider, default mass and radar signature together.

field type default meaning
radius number required nominal radius in world units, and the rock's DURABILITY - see below. The true mesh extent reaches up to 6x this (matters for min_separation)
texture asset ref required surface texture (dep://base/textures/asteroid.png is the stock rock)
invulnerable bool required true = no carving: the rock and its gravity well cannot be destroyed mid-scenario
mass Option number None the gravity parameter mu (u^3/s^2). Some ALWAYS makes this rock a well: pull is a = mu / r^2; size it by the sphere of influence you want (mu = soi_cutoff_accel * soi^2). None = the global rule (a default mass only if the radius qualifies it as a well)
impact_sound Option asset ref None played on hit (Some("dep://base/sounds/impact.wav")); omitted = silent
destroy_sound Option asset ref None played on destruction; omitted = silent
lock_signature Option number None radar signature override; None = the radius (big rocks lock far)
seed Option number None silhouette seed. Some pins the generated shape (and the derived geometric extent) across runs; None derives one from the object's own id, so a rock differs from its neighbours but keeps its shape on every load. ScatterObjects fills it deterministically from its own seed
SpawnScenarioObject((
    base: (id: "planetoid", name: "Planetoid", position: (250.0, 0.0, 0.0), rotation: (0.0, 0.0, 0.0, 1.0)),
    kind: Asteroid((
        radius: 20.0,
        texture: "dep://base/textures/asteroid.png",
        mass: Some(45000.0),
        invulnerable: true,
    )),
)),

A normal asteroid has no health pool. Hits remove signed geometry and severed pieces become debris. When no viable connected solid remains, destruction fires OnDestroyed with the rock's id and "asteroid".

Sizing a rock you want shot

radius IS the durability, so it is the only knob, and it is CUBIC. Material costs 8 hit points per cubic world unit - absolutely, on every body, whatever its size - so doubling a radius multiplies the time to break it by about eight. The stock kinetic PDC deals 4.0 per round at 100 rounds a second, which is 50 cubic units a second of held fire. A radius-3 rock is about 2.4 minutes of that; a radius-0.3 rock is a couple of seconds.

Author the size you want the fight to take. The shipped tutorial targets are radius: 0.25 and 0.3 for exactly this reason: small size, not hidden health, is what keeps a target brief now that there is no health field to turn down.

Spaceship

A spawn of a SHIP: where it sits, who flies it, which side it is on. What it IS - its section layout, its cladding - is a ship, named here by id or authored inline.

field type default meaning
hull hull source required Prototype("cargoa") names a ship by id; Inline((..)) carries a one-off hull (below)
controller controller required who flies it (below)
allegiance Option side None side override, strict RON Some(Neutral). Omitted = the controller default: Player ships fight for the player, AI ships are hostile
modifications list [] per-spawn deltas over the shared hull: (section: "fuselage", modifications: [SetHealth(500.0)]). Applied AFTER the section's own list, so the spawn wins. A section id the hull does not carry is a lint error

hull: Inline((..)) carries the same fields a ship's own hull does - sections, collapse_threshold, skin, style. Author one for a genuine one-off (a scripted battery that is a single torpedo tube); anything a second scenario would spawn belongs in the ship catalog.

An abbreviated player ship:

SpawnScenarioObject((
    base: (
        id: "player_spaceship",
        name: "Player Ship",
        position: (0.0, 0.0, 0.0),
        rotation: (0.0, 0.0, 0.0, 1.0),
    ),
    kind: Spaceship((
        controller: Player((
            input_mapping: {
                "turret_port": [Mouse(Left)],
            },
            infinite_ammo: false,
        )),
        // The shipped corvette, by id.
        hull: Prototype("cargoa"),
        // This spawn's own flight computer is hardened; every other cargoa
        // is untouched.
        modifications: [
            (section: "fuselage", modifications: [SetHealth(500.0)]),
        ],
    )),
)),

A one-off hull, authored inline:

kind: Spaceship((
    controller: None,
    hull: Inline((
        sections: [
            (
                id: "bay",
                position: (0.0, 0.0, 0.0),
                rotation: (0.0, 0.0, 0.0, 1.0),
                source: Prototype("torpedo_section"),
            ),
        ],
    )),
)),

An abbreviated AI ship:

SpawnScenarioObject((
    base: (
        id: "raider_1",
        name: "Raider",
        position: (0.0, 0.0, -300.0),
        rotation: (0.0, 0.0, 0.0, 1.0),
    ),
    kind: Spaceship((
        controller: AI((
            patrol: [(0.0, 0.0, -300.0), (80.0, 0.0, -220.0)],
            engage_delay: Some(8.0),
        )),
        hull: Prototype("cargoa_raider"),
    )),
)),

A ship id nothing authored spawns an empty root and logs an error rather than crashing, so a missing dependency is visible instead of fatal.

The controller

variant meaning
None nobody drives; the ship station-keeps
Player((..)) human-driven
AI((..)) bot-driven

Player((..)) fields:

field type default meaning
input_mapping map {} per-SECTION bindings, keyed by section id: { "turret_port": [ Mouse(Left) ] }. Values are Keyboard(<KeyCode>) / Mouse(<MouseButton>) / Gamepad(<GamepadButton>) - modifier-free buttons only
speed_cap Option number None soft manual-speed cap in u/s (the Shakedown starts at Some(25.0)); None = unbounded. Runtime mirror: SetSpeedCap
infinite_ammo bool required in shipped RON DEBUG-ONLY CHEAT: weapons built without magazines - never run dry. Only a debug build honors it; the shipped game warns and keeps the authored magazines, so author false and balance the scenario around real ammunition

AI((..)) fields:

field type default meaning
patrol list of 3-tuples [] waypoint loop while nothing hostile is detected; empty = station-keep. Legs blocked by a sized body (an asteroid's geometric radius) are flown around automatically, so routes need not measure every rock
orbit Option string None id of a gravity-well object to orbit passively. Precedence: orbit > patrol > idle
engage_range Option number None hostile-detection override (world units): a passive ship leaves its routine for a hostile inside this range instead of the 400 u default. Wide = a long-watch emplacement that wakes for targets nothing else detects; short = a ship that ignores a nearby brawl
pd_range Option number None point-defense override (world units): the guns hold fire until an inbound hostile torpedo is inside this range instead of the 150 u default. Short = staged close-in intercepts; past the turret's ~180 u reach it just wastes the opening shots
waypoint_slack Option number None patrol arrival slack override (world units) on top of the arrival standoff; the default is 25. Small = the ship turns onto the next leg closer to each waypoint. Below ~2 risks stalling outside the advance gate - author small, not zero
arrival_standoff Option number None how far from a GOTO goal this ship's computer comes to rest, instead of the engine's 50 u default. Pair a small standoff with a small waypoint_slack so a nav ship visibly REACHES its marks (the patrol turns at standoff + slack)
leash Option number None territorial tether radius; combat breaks off beyond it; None = chases freely
engage_delay Option number None arrival grace in seconds: flies its passive routine and refuses to engage until it elapses; being SHOT ends the grace instantly and permanently. The telegraphed-arrival tool

An UNARMED AI ship (no turret or torpedo section) is automatically a NON-COMBATANT: it flies its routine and never acquires, chases or shoots - no field to set. Keep its allegiance on the side you want hunted and it becomes something to defend (the Lifeline convoy).

Allegiance values: Player / Enemy / Neutral. Player and Enemy are mutually hostile; Neutral relates neutrally to everyone (stray blast damage still hurts it). Runtime flip: SetAllegiance.

The sections list

Each entry places one section in continuous ship-root space:

field type default meaning
id string required scenario-local section id; keys input_mapping (shipped ships use semantic ids such as "turret_port")
position 3-tuple required continuous offset from the ship root
rotation 4-tuple required rotation relative to the root; structural link points rotate with the section
source source required Prototype("<id>") - a catalog id, the compact reusable form - or Inline((..)) with a full section config (Ship sections for mods)
modifications list [] spawn-time deltas (below)

Section modifications - closed, data-only deltas applied at spawn:

variant payload meaning
DisableVerb(<verb>) Stop/Goto/Orbit/Lock/Rcs/PointDefense withhold a flight verb from birth (controller sections; multiple accumulate). Runtime mirror: SetControllerVerb
SetHealth(<number>) starting health override the section's health (current and max)
Rename(<string>) new name rename the section entity
SetAmmo(<number>) rounds HARD magazine: override the weapon's rounds AND strip its auto-reload - when they are gone the section is dry for good. Inert on a section with no magazine
(id: "fuselage", position: (0.0, 0.7, 0.1), rotation: (0.0, 0.0, 0.0, 1.0),
 source: Prototype("racer_fuselage"),
 modifications: [ DisableVerb(Goto), DisableVerb(Orbit) ]),

Ship structure is linted from authoritative link-point mates. A multi-section ship must form one graph. Collider AABB overlap is an Error unless the two sections directly mate, which permits intentional interlocking parts while still catching accidental embedding.

Beacon

A static, lockable, blinking nav marker with an automatic HUD chip (label, live distance, edge-clamped direction cue).

field type default meaning
label string required HUD chip text ("BEACON 1")
radius number required visual orb radius, world units
color color required orb + emissive tint, tagged: Srgba((red: 0.3, green: 0.9, blue: 1.0, alpha: 1.0))
area_radius Option number None when set, the beacon IS its own trigger area of this radius - OnEnter/OnExit fire under the beacon's id, no CreateScenarioArea needed
lock_signature Option number None radar signature override; default 20 (about a 600 u lock range) - author bigger for longer GOTO legs
SpawnScenarioObject((
    base: (id: "beacon_1", name: "BEACON 1", position: (0.0, 0.0, -350.0), rotation: (0.0, 0.0, 0.0, 1.0)),
    kind: Beacon((
        label: "BEACON 1",
        radius: 2.0,
        color: Srgba((red: 0.3, green: 0.9, blue: 1.0, alpha: 1.0)),
        area_radius: Some(70.0),
    )),
)),

SalvageCrate

A minimal fly-through pickup: a static tumbling prop that is its own trigger area. There is no inventory system - "collected" is scenario state you author: an OnEnter handler under the crate's id, paired with DespawnScenarioObject and a counter VariableSet. The HUD brackets it automatically.

field type default meaning
size number required visible box edge length, world units
area_radius number required the pickup sensor sphere ("collected" distance)
pickup_sound Option asset ref None the collection ding, player pickups only (Some("dep://base/sounds/salvage_pickup.wav") is the stock one); omitted = silent
SpawnScenarioObject((
    base: (id: "crate_1", name: "Supply Pod", position: (40.0, 5.0, -60.0), rotation: (0.0, 0.0, 0.0, 1.0)),
    kind: SalvageCrate((size: 1.5, area_radius: 8.0, pickup_sound: Some("dep://base/sounds/salvage_pickup.wav"))),
)),

Light

The scene's own lighting - and it is load-bearing: the engine spawns NO default light, so a scenario with no Light object renders black. Light takes one of two methods as an inner enum with NAMED fields in single parens (not the newtype double-paren shape).

Directional - a sun: parallel rays, direction only. The key/rim/fill workhorse.

field type default meaning
illuminance number required lux (the shipped key lights run ~11000)
color color required tagged Srgba((..))
shadows bool required shadow casting; convention: exactly ONE caster per scene
aim Option 3-tuple None point the light AT this world position, ignoring base.rotation (hand-authoring an aim quaternion is impractical); None uses the rotation

Point - a positional lamp with falloff: a star, a floodlight, a nebula glow.

field type default meaning
intensity number required lumens; needs tuning by eye against your scene scale (~2.5M for a 200 u yard lamp)
range number required contribution cutoff distance, world units
radius number required source radius (softens the terminator)
color color required tagged Srgba((..))
shadows bool required shadow casting
SpawnScenarioObject((
    base: (id: "key", name: "Key Light", position: (-60.0, 50.0, 60.0), rotation: (0.0, 0.0, 0.0, 1.0)),
    kind: Light(Directional(
        illuminance: 11000.0,
        color: Srgba((red: 1.0, green: 0.96, blue: 0.9, alpha: 1.0)),
        shadows: true,
        aim: Some((0.0, 0.0, 0.0)),
    )),
)),

The shipped scenes all use the same three-point rig - an 11000 lux warm key (the only shadow caster), a 16000 lux cold rim from behind, and a 2600 lux cool fill from the shadow side. Copy the full light blocks from assets/mods/example/example.content.ron.

Traps for the unwary

  • A despawned object fires no OnExit for itself, and a beacon/crate area dies with its object.
  • Dynamic bodies (asteroids, ships) spawned overlapping shove apart violently on the first physics step - keep spawns separated (see min_separation under ScatterObjects).
  • Every object id must be unique among live scoped entities; duplicate spawn ids in one handler are a lint Error.
  • Ships are verbose; scatter is seeded. If you are typing a 400-line ship by hand, stop and reference prototypes instead.