Space-Game

UI Flow & Screen State Machine

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

Screen State Machine

                          ┌────────────────┐
                          │      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").

Screen Descriptions

States: Showing NEW, LOAD, SETTINGS, QUIT

Transitions:

Settings (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):

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:

Aspect Ratio (BackdropMenu, 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.

Story Selector (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:

IntroScreen ("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")

PilotNameDialog (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:

Load Menu (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 — SpaceScreen

Shows: Space view with player ship, AI ships, star field, station, moon, target HUD

Inputs:

Transitions:

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).

Landing Location (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:

STATION / MOON — LocationScreen

Shows: 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)

Inputs:

While docked, SpaceScreen.update_physics() still runs in the background (ships keep moving), just without camera updates.

Transitions:

Dialogue

Shows: 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:

Exit Menu (ChoiceDialog)

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:

Possessions / Missions (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:

EndingScreen ("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())

ShopMenu

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:

ShipBrowserMenu

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:

OutfittingMenu

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:

PauseMenu (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:

Save Menu (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 (DialogBase)

Shows: A title, a one-line message, and Yes / No buttons (MenuBase.draw_buttonsui_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).

Screen-to-Screen Data Flow

PauseMenu → Save Menu

save_dialog = SaveBrowser("save", pilot_name=pilot_name)

Passes current pilot name so the browser can pre-fill a sensible default save name.

Save Menu → create_save_file

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_screengame_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.

Load Menu → SpaceScreen / LocationScreen

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.

State Transitions & Validation

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):

Input Handling Pattern

Each 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 Loop: fixed-timestep, three phases

main.py’s while running: runs three phases per iteration:

  1. Input / transitions — one big 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.
  2. Simulationadvance_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.
  3. Render — one 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.

Frame-timing metrics

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.

  1. Toggle debug (`), note the frame average and the relevant sim.* / render.* span.
  2. Make the change.
  3. Compare. A change that pushes 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.