Space-Game

Class Hierarchy & Entity Design

Class hierarchy, composition relationships, and the runtime entity model. The short project layout, shared helpers, and the WorldObject principle live in the ARCHITECTURE.md hub; config formats are in config-formats.md.

Class Hierarchy

World Objects (Position + Drawing)

WorldObject (base — x, y, graphics, get_distance(), _draw_rotated_polygon())
├── Ship (physics, rotation; owns an Autopilot via composition)
│   └── DerelictShip (a "derelict_ship" system event's wreck — reuses Ship's
│       graphics-driven draw() dimmed and frozen at a fixed angle; thrust
│       always 0, no Autopilot ever engaged, no Character/routine — pure
│       scenery until boarded. See combat-and-mining.md's "System events".)
└── LandingSite (space station or moon — config decides which)

Person (base — x, y, draw(), get_distance(), owns a Possessions)

There is no AIShip/NPC subclass anymore - every non-player character (ship-flying or not) is a Character (game/world/character.py), which composes a Person and, optionally, a Ship - it never inherits Ship, exactly like PlayerController already didn’t. A Character with no ship (any station/moon NPC) is just a body with a role; one with a ship (an AI pilot) is that same body plus the ship it flies. See “Character: AI Pilots & NPCs” below.

Every Person — the player’s own body (PlayerCharacter/PlayerController.person), every Character’s person, and an AI pilot’s Character.person — owns a Possessions (credits, owned ships, loans) by composition, not just the player. The player’s one real Possessions object is shared by reference across SpaceScreen and every LocationScreen (see SpaceScreen.get_interior_screen’s player_possessions injection) so a purchase in one location is instantly visible everywhere else.

PlayerController does not subclass Ship — it owns one (composition) and exposes x/y/velocity_x/velocity_y/angle/autopilot_active/autopilot_target as delegating properties for backward compatibility, plus handle_input() for WASD/arrow control. Character mirrors this exact property list (only meaningful when it has a ship) so both duck-type as a flyable ship the same way for SpaceScreen/autopilot code.

Screens (State Machine)

ScreenBase (implicit interface: handle_input/update/draw/get_state/restore_state)
├── SpaceScreen     — flight & exploration (owns player, AI ships, station, moon)
└── LocationScreen  — generic interior/exterior location, config-driven
                       (used for BOTH the station interior and moon locations)

Menus and dialogs don’t extend ScreenBase. They extend MenuBase (game/ui/menu_base.py) or its subclass DialogBase. Neither draws a Controls pane - every modal shows its actions as draw_button widgets in its own panel (mouse + Tab/arrow + Enter). A menu you dwell in (BackdropMenu, PauseMenu, SaveBrowser, ShopMenu, OutfittingMenu, ShipBrowserMenu, ReportMenu, StarMap); a dialog closes on any pick (ConfirmDialog, PilotNameDialog, ChoiceDialog). Each implements handle_input() + draw_content() + buttons() + panel_rect(), driven directly by the main loop. BackdropMenu covers the old Menu/StorySelector; ChoiceDialog the old LocationSelector/ExitMenu; ReportMenu (+ a builder fn) the old PossessionsMenu/MissionLog; SaveBrowser(mode=...) the old LoadMenu/SaveDialog. See DESIGN_PATTERNS.md’s “Menu vs. Dialog” and UI_FLOW.md for the full state machine.

Supporting Classes

Ship Class: Movement & Rotation

Base Class: Ship(WorldObject)

Component: Autopilot (in autopilot.py, one per Ship, composition not inheritance)

Wrapper: PlayerController

Composition: Character (game/world/character.py) — see the full section below; the AI-pilot equivalent of PlayerController. Built via Character.for_ai_pilot(...), which reads acceleration_magnitude (from ship_type’s max_thrust), max_velocity, rotation_speed from a ship_type dict (see config/stories/{story}/ship_types.json), falling back to defaults if none given. Its update() never touches ship physics directly — it runs the role’s routine each frame (a config "routine" key naming a ROUTINE_REGISTRY entry wins outright; else FACTION_ROUTINE_OVERRIDES, then ROLE_ROUTINES keyed by the pilot’s role, then IdleRoutine), which calls engage_seek()/engage_orbit() (delegated to the owned Ship), then self.ship.update() runs the real Ship/Autopilot physics.

For adding a ship type, see config-formats.md. Only subclass Ship when you need genuinely new behavior, not new stats - and prefer composing one onto a Character/PlayerController-style wrapper (as both already do) over subclassing it at all.

Person Class: Bodies & Drawing

Base Class: Person

outfit is a resolved graphics.json “outfits” asset (see get_graphics_asset(story, "outfits", outfit_id)), same pattern as ship/ station graphics. The player and AI pilots wear the story’s default_outfit ("space_suit"); a station/moon NPC wears whatever its config’s "outfit" field names, falling back to default_outfit. The default story ships ~34 outfits in graphics.json — culture standards (vherathi_hardsuit, drossholt_coveralls, …), role suits (flight_suit, security, mechanic, bartender, medic, …), and decorated variants (marshal, vherathi_honor_guard, merchant_prince, …).

Every outfit key is just a color. helmet_color / suit_color / boot_color / leg_color / sleeve_color recolor the base body (helmet optional; leg_color defaults to a darker shade of the suit, sleeve_color to the suit). Optional accessory keys each switch on one layered figure piece (fig.ACC[key]): behind the body — backpack_color, spike_color shoulder spikes, antenna_color; over the torso — chest_plate_color, sash_color diagonal band, collar_color, belt_color + shaded buckle, shoulder_color pauldrons, badge_color chest diamond; visor_color is a face band that replaces the eyes. An absent key just skips that piece, so a bare Person shows plain body colors and a new decorated outfit is still only a graphics.json entry — no drawing-code changes.

A Person opted into icon_shape/icon_color (npc config keys — see config-formats.md’s “Interior geometry”) skips the whole figure: draw() branches to _draw_icon(), a small static ui_theme.draw_item_icon glyph at its position instead of the walk-cycle body. Everything else about it — Possessions, dialogue, get_distance(), being an ordinary Character — is untouched; it’s for an NPC that’s really an inanimate object with dialogue (a derelict-ship loot container is the first user, see game/screens/space_screen/derelicts.py) rather than someone to talk to.

Person itself has no behavior/role concept - that lives on Character (see below), which owns a Person rather than subclassing it. Local NPCs are built by LocationScreen._build_local_character(): a Person (with a Dialogue attached), wrapped in a Character with ship=None.

Character: AI Pilots & NPCs

Class: Character (game/world/character.py) - composes a person (Person, always) and an optional ship (Ship, only for AI pilots), plus a role string and the Routine that role picks. This is the one mechanism behind every non-player character in the game:

ROLE_ROUTINES (in character.py) maps every role, ship-flying or not, to a Routine class - the same table, the same lookup, regardless of whether that routine flies a ship or just moves a body around a room:

Routine File Needs a ship? Used by
DockRoutine dock_routine.py Yes freighter_pilot - fly to a stop, walk in, talk, walk out, repeat
ShuttleRoutine shuttle_routine.py Yes trader_captain, courier_pilot - ping-pong stops, instant turnaround
OrbitRoutine orbit_routine.py Yes patrol_officer - circle a fixed point forever
ExplorerRoutine explorer_routine.py Yes explorer - jump to a random other system, orbit something there a while, repeat
MinerRoutine miner_routine.py Yes miner - hunt asteroids in the home system (low-level turn/thrust/fire, exactly like CombatRoutine below - never touches autopilot), dodging every other nearby rock while closing on its target, until cargo is full; fly home, walk in, sell to the quartermaster, walk out, repeat. See combat-and-mining.md
IdleRoutine idle_routine.py No default for any role with no entry - never moves
WanderRoutine wander_routine.py No resident/traveler/roommate - amble near spawn
StationaryRoutine stationary_routine.py No bartender/guard/ship_salesman/loan_officer - stand still
OrbitPlayerRoutine orbit_player_routine.py Yes Not in this table/ROLE_ROUTINES - a scripted, temporary override via Character.set_routine() (see person.escort_flag above), not a role pick; circles a moving target at a fixed radius
CombatRoutine combat_routine.py Yes Not in this table/ROLE_ROUTINES - a scripted override via Character.set_routine(), swapped in by SpaceScreen._sync_hostiles() when a pilot turns hostile (see combat-and-mining.md). Drives the ship low-level (no autopilot); sets character.firing
FollowPlayerRoutine follow_player_routine.py No Not in this table/ROLE_ROUTINES - the on-foot counterpart to OrbitPlayerRoutine; a scripted override that trails a moving target (the player) at a polite distance, wall-sliding via character.can_move_to. Driven by an interior NPC config’s "escort_flag" through LocationScreen._sync_npc_escorts() (the mirror of SpaceScreen._sync_escorts()) - e.g. Sela Cordova walking the player through Alpha Station for the station_tour mission
DepartRoutine depart_routine.py No Not in this table/ROLE_ROUTINES - a scripted override swapped in by LocationScreen._sync_npc_escorts() when an NPC config’s "depart_flag" becomes set. Walks the NPC to the nearest portal via person.step_toward, then sets character.gone; update_physics() drops gone characters from self.npcs after the NPC pass. Pair with requires_not_flag on the same flag so they stay gone on later visits. For a one-time character who leaves the scene for good (the Grey Courier in the_whisper_line)

Every Routine implements the same two methods regardless of which table row it’s in: start(character) (once, at construction) and run(character) (every frame). A ship-flying routine calls character.engage_seek(...)/character.autopilot_active (delegated to character.ship); a local routine only ever touches character.person.x/y directly - never both, since a Character only ever gets one kind of routine (whichever its role maps to).

Character.update() runs the routine, then (only if self.ship is set) steps the ship’s physics and mirrors person.x/y to it - unless self.ashore is True, meaning DockRoutine currently has the person walking around a station/moon interior independent of the (parked) ship.

ExplorerRoutine needs character.systems (the same system_id -> SystemState dict SpaceScreen owns, passed straight through by Character.for_ai_pilot(systems=..., system_id=...)) and character.system_id (which system’s SystemState.ai_ships list currently holds it) to travel: “jumping” is just removing itself from one system’s list and appending to another’s, then repositioning - see explorer_routine.py and “Multi-System Simulation” below for why that’s enough (every system reuses the same game-space coordinates, and only the active one is ever drawn/given a camera).

LandingSite: Stations & Moons

Class: LandingSite(WorldObject)

The interior config format and interior geometry rules are in config-formats.md.

SpaceScreen Responsibility

SpaceScreen contains:

SpaceScreen provides:

Why this design:

Multi-System Simulation

Class: SystemState (game/world/system_state.py) - one system’s station, moon, central_star, celestial_bodies, and ai_ships. SpaceScreen._build_system_state() builds one for every system the story defines (get_star_systems() scans config/stories/{story}/systems/*.json) at construction, and self.systems keeps every one of them alive - and ticking - for the rest of the session, not just whichever system is currently active:

State Machine: Screen Flow

BackdropMenu(main) → BackdropMenu(story) → PilotNameDialog → LocationScreen (station)
                                              ↓ (one connected interior: walk to the loan officer, then the
                                                 ship dealer; L at the dock portal boards)  ↓ (save)
                                          SpaceScreen ←→ SaveBrowser
                                              ↓ (land: L)
                            LocationScreen (station) ←→ PauseMenu

SpaceScreen → ChoiceDialog (landing spot) → LocationScreen (moon: city ←→ wilderness, L) ←→ PauseMenu

A default-story station is a single LocationScreen — one connected walkable area with just one portal (the ship dock). Multi-interior landing sites (the moon’s city/wilderness, or another story’s station) still work: each interior is its own cached LocationScreen and portals wire them together. A new pilot never sees SpaceScreen at all until they own a ship - see main.py’s "pilot_name" handler and LocationScreen.ship_available.

State transitions via return values:

Design Decisions

Why WorldObject as a base for Ship & LandingSite?

Why one generic LocationScreen instead of separate station/moon classes?

Why get_state()/restore_state() on every screen?

Why an open world instead of screen-wrapping?

See DESIGN_PATTERNS.md for reusable solutions across the codebase.