Space-Game

Weapons, Combat & Asteroid Mining

Weapon outfits, ship-to-ship combat, provocation/hostility, and asteroid breakup/mining. Ship stats and the Character/Routine model these build on are in class-hierarchy.md; outfit config lives in each story’s ship_outfits.json / asteroid_types.json.

Weapon outfits: every ship type carries at least one "weapon" slot in its ship_types.json slots list (see “ship_types.json” fields and the Outfitting section below). A weapon outfit (ship_outfits.json, slot_type: "weapon") is a full stat bundle, not just cosmetic: damage, fire_rate (cooldown frames between shots), projectile_speed, projectile_size, projectile_lifetime (frames before a shot despawns - its effective range), inaccuracy (degrees of random per-shot aim wobble), pellet_spread (degrees - the fixed fan arc projectile_count pellets spread across, a separate stat from inaccuracy), projectile_count, plus its own icon_shape/icon_color and fire_sound. The story ships four - laser_cannon (balanced baseline), pulse_blaster (fast/weak/imprecise - inaccuracy but no pellet_spread), heavy_cannon (slow single heavy slug, longest projectile_lifetime), scatter_gun (pellet_spread fan of 5, each pellet also carrying a little inaccuracy) - but adding a fifth is pure config, no code (a story can also add its own via a story-scoped ship_outfits.json, merged over the shared catalogue like any other story_catalogue file - mining_101’s rapid_laser is one: fast fire rate, a long-lived green projectile, and heavy inaccuracy (20°)). The Outfitter menu’s stats preview (OutfittingMenu._draw_stat_panel, shown live for whichever outfit is selected/focused in both its Buy and Install tabs) reads every one of these fields directly, so a new weapon’s full readout - Fire Rate converted to shots/sec, Projectile Range converted to seconds, Inaccuracy as “±n.n°” or “None (precise)”, Pellet Spread only shown when projectile_count > 1 - appears with no UI changes needed either.

Non-weapon outfits (slot_type engine / utility / shield) act entirely through a stat_modifiers dict that Ship.apply_outfits stacks additively onto the hull’s base stats, then floors: max_thrust, max_velocity, rotation_speed, cargo_capacity, and max_health (extra effective hull - the fraction of current health is preserved when it applies, so equipping mid-flight tops the bar up proportionally). ships-core (the shared outfit module) carries afterburner / ion_thruster (engine), cargo_expansion / reinforced_hull (utility, the latter now also +max_health), shield_capacitor (utility, +35 max_health, no speed cost), and sensor_array (utility, "scan": true). A scan outfit installed on the flown ship adds the target’s faction / standing / hull % to the Space View targeting panel (_HudMixin._player_has_scanner); without one the panel shows just ship type + pilot.

Firing: holding SPACE in the Space View (SpaceScreen._update_weapon_fire, rate-limited by weapon_fire_cooldown/the equipped weapon’s own fire_rate so holding the key fires repeatedly rather than once - and deliberately excluded from the “any keypress cancels autopilot” rule below, so firing doesn’t abort a run to the station) resolves the flown ship’s actual loadout via SpaceScreen._equipped_weapon_stats - the outfit installed in its first weapon slot, each field falling back individually to laser_cannon’s value so a partial config still works. A hull that has weapon slots but none installed fires nothing (_equipped_weapon_stats returns None, SPACE is a no-op); only the slot-less legacy placeholder ship falls back to laser_cannon entirely so it can still fire something. projectile_count == 1 fires one shot, randomly offset within inaccuracy degrees if the weapon has any (pulse_blaster); projectile_count > 1 fans that many pellets evenly across the pellet_spread arc, each pellet also independently offset by inaccuracy (scatter_gun sets both - a wide fan of individually-imprecise pellets), rather than a rigid comb. Each shot spawns a Projectile (game/world/projectile.py) from the ship’s nose, inheriting the ship’s own velocity, sized/coloured/shaped/lifetimed after the firing weapon’s own projectile_size/icon_shape/icon_color/projectile_lifetime and carries that weapon’s own damage. The drawn icon is rotated to the shot’s actual resultant travel direction - atan2 of its final velocity vector (ship velocity + firing velocity combined), not the raw aim angle (a rotation-capable angle param on ui_theme.draw_item_icon) - so a shot fired while the ship is drifting sideways visibly points where it’s really going, not just where it was aimed, and always matches its weapon’s own Outfitter-menu icon.

F engages autopilot (moved off SPACE so the two controls don’t collide - see handle_input’s K_f branch and CONTROLS.md).

Ship-to-ship combat. Every Ship has health / max_health (ship_types.json’s "max_health", else max(20, size*2.5); take_damage() returns True on destruction; park() repairs to full - landing is the only repair). Every Projectile carries an owner ("player" or an AI Character). SpaceScreen._check_projectile_ship_collision (run per shot in _update_projectiles, alongside the asteroid check): a "player"-owned shot hits any AI ship in the active system, an AI-owned shot hits only the player, and a shot never hits its own owner. _fire_weapon(shooter, stats, aim_angle, owner) is the shared spawn path - the player calls it from _update_weapon_fire (SPACE) with _equipped_weapon_stats(), hostile AI from _update_ai_weapon_fire with the weaker _ai_weapon_stats() (laser baseline, half fire rate), rate-limited per pilot by character.ai_fire_cooldown.

Hostility gating on hit, not just on fire. _check_projectile_ship_collision only lets a player-fired shot connect with an AI ship that’s already hostile (ship.in_combat) or is the player’s current target (SpaceScreen._get_target_object()) - a shot that grazes some other, un-targeted, peaceable ship (a miner, a passing freighter) passes harmlessly through instead of provoking it by accident; deliberately targeting a ship (T, or clicking it) and then shooting still provokes it normally. Symmetrically, an AI-fired shot only ever lands on the player if its own owner Character is still in_combat at the moment of impact - a belt-and-braces check alongside character.firing already only ever being set by CombatRoutine, in case a shot is still mid-flight the instant its owner stops being hostile.

Provocation. _provoke(ship) runs whenever a player shot lands on an AI ship: once per pilot it sets that ship’s hostile_to_player:<name> flag (so it fights back next frame and the grudge persists in the save) and docks a one-time −10 from its faction’s standing - so shooting up enough of a faction’s ships crosses HOSTILE_REP_THRESHOLD and turns the whole faction hostile. A nameless ship goes straight into CombatRoutine instead (no flag key to persist).

Hostility is a routine swap, mirroring _sync_escorts: SpaceScreen._sync_hostiles (every frame) puts a pilot into CombatRoutine (game/world/combat_routine.py - turn to face the player, close to ~PREFERRED_RANGE, set character.firing while lined up and in range) when its faction standing is <= HOSTILE_REP_THRESHOLD (-40), or a hostile_to_player:<name> / faction_hostile:<faction> flag is set - and back to its role routine otherwise (character.in_combat tracks which, like escorting). CombatRoutine drives the ship through its low-level controls (turn_left/increase_thrust/…) exactly as PlayerController does - it never touches autopilot.py / SeekMode, so it carries none of the AUTOPILOT_TESTING.md regression risk. Like OrbitPlayerRoutine it’s a scripted override, not in ROLE_ROUTINES / ROUTINE_REGISTRY.

Destruction. _destroy_ship(character) - explosions + sound, remove from its SystemState.ai_ships (the target pointer re-syncs via _validate_target). _on_player_destroyed() - explode in place, then end the run: no Rescue Service respawn. It sets SpaceScreen.game_over (plus the cargo total that went down with the ship, for the summary), which update() turns into a "game_over" return next frame - main.py hands that off to the Game Over screen (reusing the "ending" state/screen - see UI_FLOW.md’s EndingScreen section) and then the main menu. Checked once per frame after _update_projectiles (not inline in the collision, which would clobber the alive-projectile list).

Asteroid damage: Asteroid (game/world/asteroid.py) carries a health pool (max(3, size * 1.1 * health_multiplier) - deliberately low so a rock breaks up in a few hits, health_multiplier an optional per-type asteroid_field.types field defaulting to 1.0 - e.g. mining_101’s belts set 0.25 on every type to make its rocks break even faster) and take_damage(). Asteroids draw through WorldObject._draw_shaded_polygon (shared with any other world object that wants a lit, curved look) - a base fill plus a light and a dark crescent band hugging whichever stretch of the silhouette ring faces toward/away from a fixed light direction, each band’s inner edge corner-cut (Chaikin) so it reads as a smooth curve rather than flat facets, the same “silhouette edge pulled inward by a tapered depth profile” idea expand.py’s _crescent uses for character/article shading, simplified since an asteroid’s ring is convex enough to skip the interior ray-casting that pipeline needs for concave body regions. SpaceScreen._check_projectile_asteroid_collision checks every live projectile each frame against AsteroidField.asteroids; the collision radius is asteroid.size + projectile.size (roughly the asteroid’s own drawn radius - both the round-circle and jagged draw paths scale off size, see Asteroid.draw), not a fixed hitbox, so aiming reliably lands on a big asteroid’s edge and not just dead-center. Every hit (not just a destroying one) spawns a spark-burst Explosion (game/world/explosion.py

Breakup / mining, on an asteroid’s health reaching zero (SpaceScreen._destroy_asteroid):

Ore pickups: an OrePickup drifts at a slow constant velocity (plus a share of the destroyed asteroid’s own velocity) and slowly tumbles, purely cosmetic flourishes; it expires after LIFETIME_FRAMES (~60s) if never collected, fading out over its last FADE_FRAMES, so a heavily-mined field doesn’t accumulate debris without bound. Each frame, SpaceScreen._update_ore_pickups checks every live pickup against the player’s distance (PICKUP_RANGE + ship.size) and the ship’s remaining cargo room (ship.cargo_capacity - Possessions.cargo_quantity_total()) - collection tops the hold up to whatever fits, decrements the chunk’s own amount by that much, and leaves it drifting with the leftover rather than being all-or-nothing, so a chunk bigger than the remaining hold space isn’t just left untouched. A partially- or fully-collected chunk calls Possessions.add_cargo, shows a toast, and plays the "pickup" sound (SOUND.md); a completely full hold instead flashes a “CARGO FULL” warning. Hovering a drifting pickup in the main view shows a small name/quantity label (_HudMixin._draw_world_hover_tooltip, hud.py) - the same hit-test- under-cursor idea _draw_minimap_tooltip uses for minimap blips, just in world space via utils.to_screen.

Debug asteroid health readout. With constants.DEBUG_MODE on, that same world hover tooltip switches targets: hovering an asteroid instead of an ore pickup shows HP <current> / <max> (_HudMixin._hovered_asteroid, hit- tested against the asteroid’s own drawn radius, size * utils.get_scale()). It’s a diagnostic-only readout - normal play never shows asteroid health directly - useful for eyeballing whether a story’s health_multiplier tuning (above) lands where intended.

Mined ore sells like any other commodity, at a quartermaster’s "shop": {"type": "commodities"} (see config/stories/default/commodities.json’s "ore" entry) - no separate mining-specific economy code.

Asteroids, fragments, and ore pickups are all pure scenery for save purposes: none of AsteroidField, anything it spawns, Explosion, or OrePickup is captured by SpaceScreen.get_state()/restore_state() (see SAVE_SYSTEM.md) - only the ore that’s actually been collected into Possessions.cargo is. A save/load or system jump while a debris field is still drifting simply forgets it - the same choice already made for AsteroidField itself.

System events (events.json, the system-events module). A story opts a system into rare, chance-driven content by listing entries in that system’s config "events" array - see config-formats.md’s “System events” section for the exact shape. Two kinds so far:

Future event kinds (scannable anomalies, wormholes to disconnected systems) belong in the same catalogue, each adding its own resolution branch in setup.py and its own spawn path, since they won’t all fit the per-chunk-asteroid, per-system-entry-ambush, or per-system-entry-derelict shapes these first three kinds reuse.

Ship-asteroid collisions

Any ship (the player, while actually flying, or an AI ship not ashore) that physically overlaps a live asteroid in the active system takes a momentum-transfer hit - SpaceScreen._check_ship_asteroid_collisions() (mining.py, called every frame from update_physics, right after self.asteroid_field.update()), deepest-penetration asteroid wins per ship, same pattern as the projectile checks above. A ship mid-jump is excluded outright (self.jump_state for the player, character.jumping for an AI pilot - see ExplorerRoutine/jump.py): the jump animation drives its position directly, along a path that has nothing to do with normal flight, so the fiction is a jump drive passing clean through local space, not a physical transit an asteroid could actually be in the way of. _resolve_ship_asteroid_hit:

Asteroid dodging and opportunistic clearing

game/world/asteroid_avoidance.py’s steer_away_from_asteroids(character, asteroids, ignore=None, urgency=1.0) is a shared per-frame velocity nudge, deliberately outside autopilot.py - it never issues a turn/thrust command or touches SeekMode/OrbitMode, so unlike a change to autopilot itself it carries none of the regression risk AUTOPILOT_TESTING.md requires validating (the same reasoning that already keeps CombatRoutine off that surface). It finds the single most urgent threat via find_asteroid_threat - a real closest-point-of-approach prediction (both the ship and the asteroid extrapolated at their current constant velocity, not just “is something close right now”), so a fast asteroid still far away but on a genuine collision course gets reacted to before it’s already on top of the ship, while one that’s merely nearby but already past its closest approach and opening up again is left alone (an earlier version of this scored the “already past, separating” case as maximally urgent purely because its clamped time-to-approach was zero - a real bug: it livelocked a miner that had drifted near, but away from, any modestly close rock, since it read as permanently under imminent threat). The nudge itself is safe to layer on top of any routine, whether its ship is autopilot-driven (SeekMode re-corrects for it next frame, the same way it already copes with space drag) or hand-flown low-level (CombatRoutine, MinerRoutine just add it to their own turn/thrust commands that same frame). urgency (0..1, a pilot’s own dodge_urgency - see pilots.json, default 1.0) scales how hard a personality reacts; character.dodge_urgency is set once at construction (Character.for_ai_pilot). The function also returns the threat’s severity (0 if nothing threatened), so a caller can gate its own behavior on it - MinerRoutine suppresses firing and approach entirely above URGENT_DODGE_SEVERITY, and _update_ai_asteroid_clearing (below) stands down a ship’s opportunistic shot the same way - trajectory safety takes priority over both hunting and clearing, every frame, for every AI.

It also sticks to whichever asteroid it last locked onto (DODGE_LOCK_FRAMES, ~45 frames) rather than re-picking “the single most urgent one” fresh every frame - the lock lives on the Character itself (_dodge_lock/_dodge_lock_timer), invisible to callers. This is the same “sticky-decision pitfall” lesson AUTOPILOT_TESTING.md documents for autopilot.py, applying just as much here: without it, two comparably-threatening asteroids on either side of a ship can flip which one “wins” from frame to frame, and each flip swings the dodge push to a near-opposite direction - the net effect over many frames is the pushes mostly cancel and the ship barely moves, which is exactly what “the miner just sits there” looks like from outside. Locking onto one threat and committing to dodging it keeps every push pointed roughly the same way long enough to actually clear the danger zone.

SpaceScreen._update_ai_asteroid_dodge() (called every frame from update_physics, active system only) applies this to every AI ship not ashore and not currently running MinerRoutine (which already calls it itself, excluding its own hunted target - see below - so it isn’t double- applied).

SpaceScreen._update_ai_asteroid_clearing() is the “some AI will blow up asteroids in their way” behavior, for every AI ship (any role, any personality) that isn’t already fighting the player (in_combat), isn’t currently mid-dodge (_dodge_severity above URGENT_DODGE_SEVERITY - see below), and isn’t running MinerRoutine (which hunts and shoots deliberately, not opportunistically). It never steers - doing so would fight whatever’s actually driving that ship’s heading, autopilot included - so it only ever sets character.firing when an asteroid already happens to be dead ahead (within CLEARING_CONE_DEG) and close (CLEARING_RANGE) of the ship’s current heading, whatever chose it. The shot itself flows through the exact same _update_ai_weapon_fire / _fire_weapon pipeline hostile AI already uses (character.firing + character.ai_fire_cooldown), and lands on the asteroid via the ordinary projectile-asteroid check (unconditional on owner, see above) regardless of who fired it.

_update_ai_asteroid_dodge() stashes each ship’s dodge severity for that frame on Character._dodge_severity (a duck attribute, 0 when nothing threatens) precisely so _update_ai_asteroid_clearing - which runs right after it - can check it: trajectory safety always outranks taking an opportunistic shot.

Miner AI

MinerRoutine (game/world/miner_routine.py, role "miner") hunts asteroids in its home system, sells the ore, and repeats - the mining counterpart to DockRoutine’s fly/walk/talk/fly loop. Phases: "hunting" -> "returning" -> "walking_in" -> "selling" -> "walking_out" -> back to "hunting".

A pilots.json entry needs "role": "miner" (or "routine": "miner") and the pilot’s ship needs a weapon slot + real cargo_capacity (see mining_101’s mining_skiff ship type and prospector pilot for a worked example - deliberately slow (max_thrust/max_velocity/rotation_speed all well below the story’s courier) with a huge hold (cargo_capacity 70, nearly 3x the courier’s), the fiction being a hull built around cargo space rather than speed; MinerRoutine’s own pursuit geometry (PREFERRED_RANGE/FIRING_CONE_DEG) is loosened accordingly - a sluggish-turning ship needs a wider stand-off and a looser alignment window than CombatRoutine’s tighter numbers, or it just orbits its target without ever landing a shot, the same close-range pursuit-curve failure mode AUTOPILOT_TESTING.md documents for SeekMode) - _build_ai_ship’s route (an ai_ships[] entry’s "route": ["station"]) becomes MinerRoutine’s single sell destination. Asteroids are single-system scenery (AsteroidField only streams chunks for whichever system is camera-driven - see PHYSICS.md), so a miner finds nothing to hunt in a system the player isn’t currently visiting; it just idles until the player arrives.

Territory claiming. SystemState.claimed_asteroids (a plain set of live Asteroid references, one per system, not persisted - asteroids aren’t saveable scenery either) tracks which asteroid each MinerRoutine in that system currently has as its own target. _pick_target excludes anything already in that set before choosing, and _claim/_release_claim keep it in sync with self.target_asteroid (claimed the moment a target is picked, released whenever it’s given up - destroyed, out of range, abandoned, or timed out - and on cargo-full/reboard as a safety net). With several miners sharing one belt, this is what keeps them spreading out onto different rocks instead of every one of them converging on whichever single asteroid happens to be nearest to all of them.

Station point-defense

Every system’s station fires a slow, heavy shot at the nearest asteroid within STATION_DEFENSE_RANGE (650 units) every STATION_DEFENSE_INTERVAL (150 frames, ~2.5s) - SpaceScreen._update_station_defense(), called every frame from update_physics for the active system only (asteroids are single-system scenery, same reasoning as everywhere else in this doc). The cooldown lives on SystemState.station_defense_cooldown, not SpaceScreen, so each system’s station keeps its own independent timer. The shot is a plain Projectile with owner="station" (a bare string, not a Character) - _check_projectile_ship_collision’s hostility gate (getattr(owner, "in_combat", False)) always reads False for a string, so a station’s shot can only ever land on an asteroid via the ordinary (owner-unconditional) projectile-asteroid check; it can never hit a ship, hostile or otherwise. STATION_DEFENSE_DAMAGE (55) is high enough to one- or two-shot most asteroids outright - the station keeps the belt clear right around the dock for anyone nearby, not just whatever the player happens to be mining.

The shot is slow (STATION_DEFENSE_SPEED, 3 units/frame) relative to a drifting asteroid, so aiming straight at the target’s current position would often miss outright by the time the shot arrives - _lead_intercept (mining.py) solves the standard constant-velocity firing solution instead: given the asteroid’s current position/velocity and the shot’s own speed, it finds the smallest positive t where |asteroid_pos + asteroid_vel*t - station_pos| = shot_speed*t (a quadratic in t) and aims at the predicted position at that time, falling back to the asteroid’s current position if no positive-time solution exists (e.g. it’s already outrunning the shot).