Menu hierarchy, screen transitions, and state management.
Menu vs. dialog classes: every modal below extends MenuBase (a
menu - navigate freely, doesn’t close on an action) or DialogBase (a
dialog - closes on any pick). Neither draws a Controls pane; both show
their actions as draw_button widgets in their own panel (mouse + Tab/arrow
BackdropMenu; the “landing spot” and “where to?” pickers are both
ChoiceDialog; the possessions and mission read-outs are both ReportMenu
(+ a *_report() builder fn); load and save are SaveBrowser(mode="load"
|"save"). State names in main.py ("menu", "load", "exit_menu", …)
are unchanged. ┌────────────────┐
│ MENU │
│ NEW/LOAD/QUIT │
└───────┬─────────┘
┌────────────┴────────────┐
┌──────▼───────┐ ┌──────▼──────┐
│ STORY SELECT │ │ LOAD MENU │
└──────┬───────┘ │(scrollable) │
┌──────▼───────┐ └──────┬──────┘
│ PILOT NAME │ │
└──────┬───────┘ │
┌──────▼───────┐ │
│ INTRO │ (if story has │
│ (IntroScreen)│ an "intro") │
└──────┬───────┘ │
│ │
┌──────▼──────────────────────────▼──┐
│ GAME (SpaceScreen) │◄───────────┐
└───┬───────────────────────────┬─────┘ │
(G: land on station) (G: land on moon) │
│ │ │
┌───────▼────────┐ ┌─────────▼─────────┐ │
│ STATION │ │ LANDING LOCATION │ │
│ (LocationScreen)│ │ ChoiceDialog │ │
└───────┬────────┘ └─────────┬─────────┘ │
(G: exit) ─────────────► GAME │ │
│ ┌─────────▼─────────┐ │
│ │ MOON │ │
│ │ (LocationScreen) │ │
│ └─────────┬─────────┘ │
│ (G: exit) ────────────────┘
│ │
┌─────────▼────────────────────────────────────────────────▼──┐
│ PAUSE MENU │
│ Resume / Save / Load / Settings / Quit │
└───┬───────────────────────────┬───────────────────────┬────┘
│ │ │
┌──────────────┐ ┌────────────────────┐ ┌──────────────────┐
│ SAVE BROWSER │ │ OVERWRITE CONFIRM │ │ DELETE CONFIRM │
│ SaveBrowser │──(D)────►│ (ConfirmDialog) │ │ (ConfirmDialog) │
└──────────────┘ └────────────────────┘ └──────────────────┘
The Load screen (SaveBrowser("load")) also has its own delete flow (its
own ConfirmDialog instance) for removing a save directly, independent of
the Pause-menu save browser’s delete flow shown above.
The diagram above shows the simple case where a LocationScreen’s exit
(G near a portal) leads to only one place, and so acts immediately. A
location can have more than one portal (see STATION / MOON below); when
the one the player is standing next to has connected_locations and/or
return_to_ship adding up to more than one destination, G instead opens
the exit ChoiceDialog - the player picks “Return to Ship” (→ GAME) or a
connected location (→ that location’s own LocationScreen, staying in
"station"/"moon").
BackdropMenu)States: Showing NEW, LOAD, SETTINGS, QUIT
main.py’s main_menu() builds the rows; recreated each time the game
returns to the menuTransitions:
SaveBrowser("load")"settings" state; its Aspect Ratio sub-picker is
"settings_aspect")BackdropMenu, allow_cancel=True, tabs=(...))Reachable from: the main menu and the in-game pause menu (Settings
button). A main() local, settings_return_screen, records "pause" vs. the
main menu so Back returns to the right place. Built by
settings_menu(aspect, tab); main.SETTINGS_TABS is the tab list (just
["Video"] today — a tab click returns "tab:<label>" and rebuilds the menu).
Shows (Video tab):
"aa_cycle") — cycles
constants.AA_MODE through constants.AA_MODES, persists it to
settings.json (aa_mode), and drops the offscreen buffer
(main._invalidate_hires_surface()). "gfxdraw" is per-primitive AA in
game/aa_draw.py (world/asset draw sites route their fills through
aa.polygon/aa.circle, which lay a pygame.gfxdraw aa* outline over the
shape); "supersample" makes PHASE 3 render the whole frame to a 2×-logical
surface and smoothscale it down onto screen (see “Frame timing” below).
Off by default. Old supersample_aa: true settings.json files are read as
"supersample" once.main.resolutions_for_aspect() — the constants.VIDEO_RESOLUTIONS in that
group that fit the desktop, plus the native resolution when it belongs
there, sorted small→large), the active one marked “Current resolution” and
the native one “Native resolution”.The browsed aspect (a main() local) defaults to
aspect_label(logical_resolution) and is rebuilt after each pick so the
markers follow. The first-run / fallback default resolution is the native one.
Transitions:
"settings_aspect" state)main.apply_resolution() re-inits the display at that
SCALED logical size and writes it to settings.json; stays on this screenBackdropMenu, allow_cancel=True)Shows: main.available_aspects() — the aspect labels (from main.ASPECTS,
bucketed within 4%) that have at least one fitting resolution, native first.
The one in use is marked “Selected”, the monitor’s own “Native” when it isn’t
the selected one. Built by video_aspect_menu(selected).
Transitions:
The chosen resolution is the fixed SCALED logical surface (see “Frame
timing” below): the game always renders at it and SDL scales the result to
whatever size the window is dragged to. Switching it needs a full display
tear-down (pygame.display.quit()/init()) because a live SCALED renderer
can’t be re-set_mode()‘d — ~150 ms, which is why it’s a deliberate
menu-only action rather than something wired to VIDEORESIZE.
BackdropMenu, allow_cancel=True)Shows: List of playable stories, scanned from config/stories/*/story.json by main.py’s story_menu_rows(), with each story’s description
Inputs: UP/DOWN or W/S: navigate · RETURN: select · ESC: cancel
Transitions:
PilotNameDialog (remembers selected_story)"intro" state)Shows: The story’s opening crawl — a ReportMenu subclass
(game/ui/intro_screen.py) with one Begin button. intro_report(story,
pilot_name) reads story.json’s "intro" block ({"title", "body": [para,
...]}) and substitutes {pilot} with the entered name. Only stories that
define an "intro" block route through here (has_intro()); others go
straight from PilotNameDialog to the world.
Trigger: after PilotNameDialog confirms, main.py resolves the start
screen from story.json’s "start" block as normal, then — if the story has
an intro — stashes that screen in intro_next_screen and switches to
"intro". begin_new_game() has already run (and may have armed the tutorial
dialogue); the world is simply frozen (step_world() is a no-op for
"intro") until Begin. Menu music keeps playing ("intro" is in
music.MENU_SCENES).
Inputs: click Begin, or ESC / Enter
Transitions: → intro_next_screen ("game" / "station" / "moon")
DialogBase)Shows: Text entry box for the pilot’s name (30 char max) plus Start / Cancel buttons
Inputs: Type: add to name · BACKSPACE: delete last char · Left/Right/Tab: move between buttons · RETURN/click Start (name non-empty): confirm · ESC/click Cancel: cancel
Transitions:
SpaceScreen created with pilot_name and story, then game_screen.begin_new_game() returns where to drop the player from story.json’s "start" block: "station"/"moon" at the given interior key, or "space" → "game". The default story starts in "station" at "default" (0 credits, no ship - see the station layout under STATION / MOON below). "start" also seeds starting credits / ship / spare outfits / personal items / story flags.SaveBrowser, mode="load")Shows: All save files from saves/ directory, 5 at a time
Inputs: UP/DOWN or W/S: navigate (scrolls at boundaries) · RETURN: load · D: delete (opens its own ConfirmDialog) · ESC: cancel
Transitions:
game_state["location"] from the save ("space" / "station" / "moon") and jumps straight to the matching screen with state restored:
"space" → SpaceScreen"station" → SpaceScreen (background) + LocationScreen for the station interior"moon" → SpaceScreen (background) + LocationScreen for the saved moon locationSpaceScreenShows: Space view with player ship, AI ships, star field, station, moon, target HUD
Inputs:
update()/update_background_locations() while game_screen.active_dialogue is set)Transitions:
LocationScreen (station interior)ChoiceDialog ("select_location")The Message Log fills the gap under the Controls pane. draw_message_log’s
max_height (both screens’ _draw_hud, computed from controls_rect.bottom
down to the screen edge) caps the panel at however much room is actually
left below Controls, instead of a fixed line count - so it grows when C
collapses Controls to its two-liner and shrinks back when Controls expands.
A backlog longer than that still scrolls (mouse wheel) same as before.
One-way messages (Message Log + hail banner + unread ping). Every path that
posts one — story dispatches (_check_dispatches), mission stage messages
(_deliver_stage_message), beacon relights (_check_beacons), pilot
proximity hails (_check_one_way_hails), the rescue notice — funnels through
_post_message, which queues rather than posts directly. _pump_message_queue
(every frame from update_physics) releases one, then holds the rest for
MESSAGE_SPACING_FRAMES (~7.5s). An isolated message lands at once; a burst
spaces out so banners don’t stack and the ping doesn’t drone.
The unread alert loops until the player clicks it. Every message (not
just plot-vital ones - a beacon relight or a pilot’s proximity hail count
too) sets self._unread_alert_pinned when it posts. Normally the Message
Log’s red light blinks and pings exactly MESSAGE_ALERT_BLINKS times
(message_alert_timer counts down to 0 and stays there - see
message_alert_state). While _unread_alert_pinned is set,
update()/update_physics() re-arms message_alert_timer back to
MESSAGE_ALERT_FRAMES every time it would otherwise reach 0, looping the
same blink/ping cycle indefinitely instead of letting it go quiet -
draw_message_log’s pinned flag also puts up a steady (non-blinking) red
“CLICK TO STOP” label next to the light so there’s an explicit target, not
just a light blinking at the player. MESSAGE_ALERT_BLINK_FRAMES (36 frames,
~0.6s per half-cycle - a ping about every 1.2s) sets the pace for both the
blink and the ping, whether it’s mid-loop or on its first pass.
It’s only silenced by the player actually clicking the Messages pane -
handle_input’s MOUSEBUTTONDOWN branch checks _message_log_rect first
(before minimap / world-click handling), and on a hit clears
_unread_alert_pinned and zeroes message_alert_timer immediately.
SpaceScreen._unread_alert_pinned and LocationScreen._unread_alert_pinned
are properties that both proxy to the same possessions.unread_alert_pinned
(see Possessions.__init__’s own comment) rather than keeping independent
state - a dispatch/beacon can post via SpaceScreen._post_message while
the player is docked (npc_sync.py’s checks run “while docked too”), which
only LocationScreen is actually on screen to show; without the shared
flag, dismissing it there left SpaceScreen’s copy still pinned and the
alert would start looping again the moment the player undocked.
LocationScreen._refresh_messages still separately picks up any message
that landed on the shared possessions.message_log while the player was
flying, for its own banner/blink timing - just the “has this been
dismissed” bit is shared now, not the whole state.
The alert stays quiet while a hail (K_r) or NPC conversation (T) is open -
in both cases the Messages pane isn’t even drawn (draw_hud and not
self.active_dialogue gates it in both _draw_hud/draw()), so it
shouldn’t be audible either. SpaceScreen’s side of this falls out of
step_world skipping game_screen.update() entirely while
active_dialogue is set (a hail freezes the whole screen); LocationScreen.
update() isn’t frozen the same way (an NPC conversation still lets other
NPCs’ update_physics tick, just not movement/escort/ambient checks - see
there), so its own ping loop holds separately on not self.active_dialogue.
Guideline for content authors: a single player action should trigger at
most one one-way message. Don’t emit two in the same beat and lean on the
queue to sort them out — the queue is a safety net for genuinely independent
events coinciding (a beacon relighting as a dispatch lands), not a spacing
tool you design against. In particular a dispatch/NPC that start_missions
something already spoke — its body/dialogue is stage 0’s message, so author
stage 0 with no one_way_message (the engine skips it for dispatch-started
missions; the NPC start_mission: path never delivered it).
ChoiceDialog)Shows: Moon landing sub-location choices (City / Wilderness) as a button column, built from the moon’s interiors config by landing_location_options() (game/app/loop_helpers.py)
Inputs: UP/DOWN or W/S: move between buttons · RETURN/click: pick · ESC: cancel (returns to SpaceScreen)
Transitions:
LocationScreen for the chosen moon locationLocationScreenShows: Top-down walkable view of the interior/exterior with NPCs. One generic, config-driven class used for both the station interior and every moon location — not a station-only or moon-only screen.
A default-story station is a single interior (key "default"): one
connected walkable area (polygon rooms unioned - concourse, bar, credit
union, ship dock, resin quarter) with one portal, the ship dock
(return_to_ship: true, disabled until a ship is owned - see
ship_available below). The new-game start, the loan officer, the ship
salesman, and the outfitter all live in that one interior. See
config/stories/default/systems/sol_alpha.json.
A location can still have more than one portal (LocationScreen.portals)
city ↔ wilderness, or a multi-interior story station: each
portal leads somewhere specific, and walking back through the portal you
arrived from always leads back the way you came
(LocationScreen.arrive_from() / portal_for()). A config with a single
exit may still use the flat "entrance" / "connected_locations" /
"return_to_ship" keys, normalized into a one-item portal list internally.Inputs:
get_available_exit_options(), scoped to whichever portal is nearest:
"exit" → GAME, or "exit_to:<key>" → that connected location)ChoiceDialog, so an unusable option is still visible with its reason instead of G doing nothingDialogue (see below) · Q/E: cycle targetReportMenu · 3: mission ReportMenuWhile docked, SpaceScreen.update_physics() still runs in the background (ships keep moving), just without camera updates.
Transitions:
LocationScreenChoiceDialog"shop" config) → that NPC’s Dialogue, always restarted at its root node"shop" config) → ShopMenu instead of Dialoguestar_map (returns to this interior; jump disabled while docked) · 2 → possessions ReportMenu · 3 → mission ReportMenuShows: A conversation tree (game/world/dialogue.py) - most NPCs are a
single node with closing options only (“Thanks”/”Leave”, built via
Dialogue.from_flat); a few (Bartender, the ship salesman, the loan
officer) have a real dialogue_tree in config where an option’s "next"
leads to another node instead of closing. An option can also carry an
"action" ("buy_ship:<ship_type_id>", "take_loan") applied by
LocationScreen right before advancing - this is how buying a ship or
taking a loan works today. An action option unaffordable or otherwise
blocked (LocationScreen._option_blocked_reason) renders dim with the
reason and can’t be selected. NPCs selling commodities or personal items use
ShopMenu instead (see below) - a "shop" config key bypasses Dialogue
entirely rather than being another dialogue action.
Inputs: UP/DOWN or W/S: navigate · RETURN: choose (advance, close, or apply an action then advance/close) · ESC: close immediately
Transitions:
"next": null) → dialogue closesChoiceDialog)Shows: The destinations offered by the current location’s exit as a
column of buttons - each connected_locations key (labeled from that
sibling interior’s own "label") plus “Return to Ship” if return_to_ship
allows it, built by main.py’s exit_options(). Shown whenever there’s
more than one configured option, or the single option isn’t currently usable
(e.g. “ship” with get_exit_disabled_reasons() returning {"ship": "no ship
docked here"} - the station dock before a purchase). Disabled entries render
as dimmed buttons and can’t be selected. AI pilots (DockRoutine) pick from
this same option list automatically via ROLE_EXIT_PREFERENCE instead of
getting a dialog.
Inputs: UP/DOWN or W/S: move between buttons · RETURN/click: pick · ESC: cancel (stay in the current location)
Transitions:
LocationScreen (still "station"/"moon")ReportMenu)Shows: A read-only, one- or two-column text report. possessions_report()
(2): credits, owned ships, loans, the current ship’s live stats
(thrust/max velocity/rotation/cargo usage - via the optional ship arg,
PlayerController.ship, so it reflects installed outfits immediately),
cargo, personal items, installed/spare outfits. mission_report() (3):
each mission’s stages with [x] / -> markers, hiding stages not yet
reached. Both live in game/ui/report_menu.py, drawn over whichever screen
opened them.
Inputs: 2 (possessions) or 3 (missions) or ESC: close
Transitions:
possessions_return_screen / missions_return_screen in main.py)"ending" state)Shows: The end-of-game epilogue - a ReportMenu subclass
(game/ui/ending_screen.py) with one Return to Menu button. ending_report()
assembles the story’s endings.json entry (title + epilogue paragraphs) plus
one line per faction chosen by the player’s final standing with it.
Trigger: an "end_story:<id>" dialogue action sets the story_over /
ending:<id> flags; main.py checks utils.resolve_ending() once per frame
after the game/station/moon input phase and switches current_screen to
"ending" (a full modal - step_world() does nothing for it). A loaded save
whose story_over flag is already set drops straight into it.
Game Over reuses this same state. The player’s hull hitting zero
(SpaceScreen._on_player_destroyed, game/screens/space_screen/combat.py) is
final, not a Rescue Service respawn: it sets SpaceScreen.game_over, which
update() reads and returns "game_over" for; step_world()
(game/app/loop_helpers.py) propagates that, and main.py’s PHASE 2 handler
builds EndingScreen(*game_over_report(...)) (game/ui/ending_screen.py) and
switches to "ending" exactly as the story-epilogue path does - same report
frame and Return to Menu button, just not driven by endings.json.
Inputs: click Return to Menu, or ESC / Enter
Transitions: → "menu" (rebuilds main_menu())
Shows: Buy/sell for commodities or personal items - game/ui/
shop_menu.py. Opened by talking (T) to an NPC whose config has a "shop"
key ({"type": "commodities"|"items", "stock": [...], "sell_multiplier": ...})
instead of that NPC’s Dialogue. Buy tab lists stock priced from
commodities.json/items.json; Sell tab lists whatever’s currently in
possessions.cargo/.items for that category, priced at
base_price * sell_multiplier. Drawn over whichever screen it was opened
from (station or moon), same overlay pattern as the possessions ReportMenu.
Ships and ship outfits get their own dedicated menus, not this one.
Inputs: LEFT/RIGHT or TAB: switch Buy/Sell · UP/DOWN or W/S: navigate · RETURN: buy/sell one unit of the selected item · ESC: close
Transitions:
shop_return_screen in main.py)Shows: Ship-buying with a live preview - game/ui/ship_browser_menu.py.
Opened the same way as ShopMenu (T on an NPC with a "shop" config), but
for "type": "ships" - build_shop_menu() (game/app/loop_helpers.py) dispatches to this
instead of ShopMenu based on the shop config’s type. Left: the shop’s
stock ship-type ids. Right: a live preview (ui_theme.draw_ship_glyph) and
stat readout for whichever is selected. Enter opens a ConfirmDialog
(returned from active_popup(), so MenuBase.draw draws it on top instead
of this menu’s Close button - see the ConfirmDialog note below); confirming
calls the injected on_buy callback, which main.py wires to
LocationScreen.buy_ship() - the same mutation the old "buy_ship:<id>"
dialogue action performed (spend, add_ship, on_ship_purchased
callback), now shared by both purchase paths. The station’s ship salesman
(sol_alpha.json’s Dax Renner) uses this instead of a dialogue_tree.
Inputs: UP/DOWN or W/S: navigate · RETURN: open purchase confirmation · Y/N or ESC: confirm/cancel the pending purchase · ESC: close (no purchase pending)
Transitions:
shop_return_screen in main.py)Shows: Buy and install ship outfits - game/ui/outfitting_menu.py.
Opened like ShopMenu/ShipBrowserMenu (T on a "shop" NPC), for
"type": "outfits" - build_shop_menu() (game/app/loop_helpers.py) dispatches here,
passing the current ship type (possessions.owned_ships[-1], or None if
no ship is owned yet) and game_screen.reapply_outfits as the
stats-refresh callback. Buy tab: a ShopMenu-style list, but purchases add
to possessions.owned_outfits (spare, uninstalled) rather than equipping.
Install tab: a diagram of the current ship’s slots (from
ship_types.json) plus the spare-outfits list; drag a spare onto a
matching-type slot to equip (or drag an installed one out to unequip), or
use the keyboard fallback (Tab: switch focus column, arrows: navigate,
Enter: open a compatible-outfit picker on an empty focused slot, or
uninstall directly on an occupied one). Every equip/uninstall calls
on_outfits_changed, which re-runs SpaceScreen._apply_ship_type so the
flown ship’s stats update immediately - see SpaceScreen.reapply_outfits.
Inputs: LEFT/RIGHT: switch Buy/Install · (Install tab) TAB: switch focus column · UP/DOWN or W/S: navigate · RETURN: buy (Buy tab) / open picker or uninstall (Install tab, depending on slot state) · mouse drag: equip/unequip directly · ESC: close (or cancel an open picker first)
Transitions:
shop_return_screen in main.py)MenuBase)Shows: A column of buttons - Resume / Save Game / Load Game / Settings / Quit to Menu - plus an optional “Saved!” banner.
Inputs: UP/DOWN or W/S: move between buttons · RETURN or click: press · ESC: resume (quick exit)
Transitions:
previous_screen)SaveBrowser("save")SaveBrowser("load") (with load_return_screen = "pause", so cancelling it returns here instead of the Main Menu; a successful load replaces the running game and goes to "game"/"station"/"moon")"settings" state, settings_return_screen = "pause", so Back returns here)SaveBrowser, mode="save")Default name: pre-filled as "{pilot_name} - {timestamp}"
Two modes:
Inputs (list mode): UP/DOWN or W/S: navigate (scrolling) · RETURN: overwrite selected → ConfirmDialog (“Overwrite Save?”) · N: switch to input mode · D: delete selected → ConfirmDialog (“Delete Save?”) · ESC: cancel
Transitions:
ConfirmDialog (Y = "confirm", N/ESC = "cancel")DialogBase)Shows: A title, a one-line message, and Yes / No buttons
(MenuBase.draw_buttons → ui_theme.draw_button, green / muted-red) with a
shortcut-reminder line - all inside its own glass panel. A dialog closes
on any pick. When it’s a sub-dialog of a menu (ShipBrowserMenu.
active_popup() returns self.confirm), MenuBase.draw draws it on top.
Panel via modal_panel_rect(). Used for ship
purchases (ShipBrowserMenu) and save overwrite/delete confirmations.
Starts with No highlighted (the safe default for the destructive uses).
Inputs: Left/Right or Tab: move between buttons · Enter: pick the
highlighted one · Y: confirm · N / ESC: cancel · mouse hover highlights a
button, click acts on it. Returns ("confirm", context_data) or
("cancel", None).
save_dialog = SaveBrowser("save", pilot_name=pilot_name)
Passes current pilot name so the browser can pre-fill a sensible default save name.
create_save_file(
pilot_name,
save_description,
game_screen.system_config,
{},
game_screen.get_state() # Current game state
)
Saves the original system config alongside the current game state. Which get_state()
is called depends on previous_screen — game_screen, station_interior, or
moon_interior — and game_state["location"] is set accordingly ("space" /
"station" / "moon", plus "station_location" / "moon_location" for
which interior). Both SpaceScreen.get_state() and LocationScreen.get_state()
also include "possessions" (credits/owned ships/loans) - see
SAVE_SYSTEM.md.
save_data = load_save_file(filename)
pilot_name = save_data.get("pilot_name", "")
game_screen = SpaceScreen(save_data.get("system", {}), pilot_name=pilot_name)
game_screen.restore_state(save_data.get("game_state", {}))
See SAVE_SYSTEM.md for the full file format.
The main menu is rebuilt (main.py’s main_menu()) every time the game
returns to "menu" - from QUIT, from cancelling the load screen, or from a
completed load being abandoned - so nothing stale carries over.
Valid transitions (current_screen values in main.py):
"menu" → "story_select" → "pilot_name" → ("intro", if the story has an "intro" block) → "station" / "moon" / "game" per story.json’s "start" block (default story: "station" interior, ship-less)
"menu" → "load" → "game" / "station" / "moon" (whatever location the save has)
"pause" → "load" (Load Game; load_return_screen = "pause") → "game" / "station" / "moon" on load, or back to "pause" on cancel
"game" → "station" (land near station) or "select_location" → "moon" (land near moon)
"station" / "moon" → "exit_menu" (G, exit has multiple destinations, or its one destination isn’t usable yet) → "game", or back to "station"/"moon" (a different interior, or ESC/cancel)
"game" / "station" / "moon" → "possessions" (2) → back to whichever of the three it came from
"game" / "station" / "moon" → "star_map" (1) → back to whichever of the three it came from (star_map_return_screen; try_jump() only runs when that is "game")
"station" / "moon" → "shop" (T, on an NPC with a "shop" config) → back to whichever of the two it came from
"game" / "station" / "moon" → "pause" (ESC) → back to previous_screen (Resume) or "menu" (Quit)
Invalid (prevented by code):
ConfirmDialog is openLocationScreen only exits (G) within entrance_range of a portalChoiceDialog only appears when get_exit_options() has more than one entry, or the single entry isn’t usable (get_exit_disabled_reasons()); with exactly one usable entry it’s skipped (immediate exit)landing_distance) and slow enough (speed < 0.4)"action" (LocationScreen._option_blocked_reason) can’t be selected - RETURN on it is a no-opEach screen handles its own input:
class Screen:
def handle_input(self, events):
for event in events:
if event.type == pygame.KEYDOWN:
# Process key and return action string
return None # No state change
Main loop interprets action strings and manages state:
if current_screen == "menu":
action = menu.handle_input(events)
if action == "new":
story_selector = BackdropMenu("SELECT STORY", story_menu_rows(), seed=4242, allow_cancel=True)
current_screen = "story_select"
elif action == "load":
load_menu = SaveBrowser("load")
current_screen = "load"
This separation makes it easy to test input handlers independently.
main.py’s while running: runs three phases per iteration:
if current_screen == … that calls each
screen’s handle_input(events) and applies the resulting state changes
(building menus, swapping current_screen, …). No update()/draw() here.
A transition requested here lands before phase 2, so the accumulator
never simulates the screen the player just left.advance_accumulator() (game/utils.py) converts the
real milliseconds since the last frame (clock.tick(FPS)) into a whole
number of fixed SIM_STEP (1/60 s) steps, and step_world() is called
that many times. step_world() is the single simulation entry point: it
does exactly what each screen branch used to do inline for simulation
(SpaceScreen.update() / update_physics(), LocationScreen.update(),
update_background_locations(), and the per-step countdown timers inside
those). Screens that freeze the world (every menu/dialog, the star map,
pause, any open active_dialogue) are no-ops here, exactly as before.
When SpaceScreen.update() returns "land" (autopilot auto-land) the
step returns it, main() applies the landing and stops draining.if current_screen == … that draws the current screen
(modal screens redraw the frozen backdrop with draw_hud=False, then
their overlay), then pygame.display.flip(). When
constants.AA_MODE == "supersample" (Settings → Video), the whole if
block draws to a 2×-logical offscreen surface (main._hires_target()) with
the reported screen size temporarily doubled — everything is
resolution-independent, so it just renders bigger — then
pygame.transform.smoothscale() shrinks it onto screen (timed as the
render.supersample span). Off by default; ~4× fill + a downscale per
frame, which is why it’s opt-in. Input (phase 1) always runs at the
logical size, so hit-testing is unaffected. The "gfxdraw" AA mode is
unrelated to this block — it’s per-primitive in game/aa_draw.py at the
world/asset draw sites.The window is opened by main.open_window() with pygame.RESIZABLE |
pygame.SCALED and vsync=1. SCALED backs the window with a GPU renderer —
the only way SDL2 vsyncs a non-OpenGL window on many drivers — and makes the
logical surface a fixed size (one of constants.VIDEO_RESOLUTIONS, chosen
in Settings → Video, default = the native desktop resolution,
remembered in settings.json). SDL scales that surface to whatever
size the user drags the window to and remaps mouse coords, so VIDEORESIZE
needs no handling at all — the loop ignores it. SCALED’s two catches —
the window can’t be dragged below the logical size, and a live SCALED renderer
can’t be re-set_mode()‘d — are why the resolution is a fixed menu choice
applied via a full display re-init (main.apply_resolution()), not something
that tracks the window. If SCALED won’t initialise, open_window() falls
back to plain RESIZABLE and clock.tick(FPS) paces (a camera pan may tear).
Trade-off accepted: above the logical size the image is GPU-upscaled (slightly
soft; exact and crisp at an integer multiple), and an off-aspect window gets
letterbox bars. The alternative — plain RESIZABLE re-set_mode()‘d on every
VIDEORESIZE — is crisp at every size but gets no vsync on drivers that
only sync SCALED, and tears on every pan.
Frame cap ↔ vsync: open_window() measures whether the requested vsync
is actually pacing flips (a short burst of flips — vsync=1 is only a request)
and records it in main.vsync_display. When vsync is real, the flip is the
frame pacer and clock.tick() only enforces a loose safety cap (FPS * 4) —
a tight clock.tick(FPS) on top of vsync makes the sleep overshoot into the
next vblank, stretching that frame to two refreshes (judder on a pan). With
no vsync, clock.tick(FPS) is the only thing holding 60. Either way the
accumulator is fed real_dt from a time.perf_counter() delta, not
clock.tick()’s whole-millisecond return (16 vs 17 for a true 16.667 ms
frame is enough quantization to cost the sim a step).
SIM_STEP must stay 1/60: every physics constant and per-step timer is
calibrated to a 1/60 s step. On a machine holding ~60 FPS phase 2 runs
exactly once per render. advance_accumulator deliberately runs exactly
one step for any frame worth 0.5–2.5 steps (not floor): a plain Fiedler
accumulator emits a 0-step frame next to a 2-step one under normal jitter,
and a “60 Hz” panel that’s really 59.94 guarantees that ~every 20 s — a
visible lurch on a pan. Multi-step catch-up (up to MAX_STEPS_PER_FRAME)
only kicks in on a sustained slowdown; MAX_FRAME_TIME still clamps a
debugger/asset-load hitch. The cost is that a persistent sub-step surplus is
dropped — the sim tracks the display rate, not the wall clock, drifting
<0.1% (nothing measures real seconds). This is frame-rate independence,
not render interpolation — draw() still paints the latest sim state.
Menu/dialog animations (pause_menu.update()’s success-banner countdown)
are render-side and stay in phase 3 — they’re not simulation.
The loop times each phase with time.perf_counter() deltas and feeds them to
game/perf_metrics.py’s shared metrics object once per iteration
(metrics.record(...)), along with n_steps (the catch-up sim-step count) and
clock.get_fps(). Finer-grained sub-sections are wrapped in
with metrics.span("<name>"): at their call site — currently render.starfield
/ render.world / render.hud in SpaceScreen.draw, sim.player /
sim.ai_ships / sim.missions in SpaceScreen.update_physics, and sim.npcs /
render.location_entities in LocationScreen. All of this runs unconditionally
(it’s a few perf_counter calls and deque appends per frame); only the
bottom-left overlay that perf_metrics.draw_overlay(screen) paints is gated on
constants.DEBUG_MODE. Everything shown is a rolling average + peak over the
last WINDOW frames (~2 s), except the final space zoom / interior zoom
line: an instantaneous read of whichever screen instance actually drew that
frame’s camera_zoom (main.py resolves this the same way its draw branches
do, including through pause/missions/shop to the screen they overlay),
passed straight into draw_overlay() rather than through PerfMetrics. Blank
on a screen with no camera (menus, dialogs).
Agents — the frame budget. The game holds 60 FPS by doing all of a frame’s
work (input + simulation + render + present) in under 16.67 ms. Monitor the
panel whenever your change adds per-frame work: a new drawable, an AI/physics
routine, a per-frame scan over all entities/systems/interiors, a new
update() / draw() path, or anything in main.py’s loop.
Viewport culling. Anything drawn per-frame per-object in the world (not the
HUD) must skip work for objects off screen. utils.visible_world_bounds(margin)
gives the on-screen world rect (it shrinks correctly as the camera zooms in —
the StarField and LocationScreen both cull against it). LocationScreen
culls floor-pattern tiles, structures (by a precomputed _structure_meta world
bbox), and NPCs; a big concourse holds far more of each than are ever visible at
interior zoom. Person bodies (Person._draw_pipeline_body) fold the camera
transform into two multiply-adds and pass float points straight to
pygame.draw.polygon (no per-vertex round()); gfxdraw mode rounds
internally so the look is unchanged. A standing figure (walk intensity
~0) skips the ~90-polygon fill entirely: _blit_idle_body rasterises its
rest pose once to an SRCALPHA sprite (one-entry cache keyed by view
scale / facing / AA mode / pose identity — rebuilt only on a zoom or a
turn) and blits that. In gfxdraw AA mode the sprite is drawn oversized
with plain polygons and smoothscaled down — gfxdraw’s own aapolygon
feathers toward transparent black on an alpha surface, baking a dark rim
onto each part. This is what keeps a crowded interior (Hub Control:
~11 NPCs, mostly idle) inside the frame budget at min zoom, where nothing
culls.
`), note the frame average and the relevant
sim.* / render.* span.frame toward the budget or balloons a span
is a regression even if it “looks fine” — the same failure mode
AUTOPILOT_TESTING.md describes.Instrument genuinely new expensive sections by wrapping them at the call site:
from game.perf_metrics import metrics as perf
with perf.span("sim.<name>"): # or "render.<name>"
...
so the cost shows in the panel and the next agent sees any regression. Keep
spans sharing a prefix non-overlapping (a phase’s spans should sum to
something meaningful). Recording is cheap and always on; only the overlay is
gated on constants.DEBUG_MODE.
See architecture/class-hierarchy.md for class hierarchy.