Files
client/CLAUDE.md
T
anekdotin d3bed34c6c Add game mode categories, character creation, on-foot ship/hub mode, RAM currency, and in-ship navigation
Bundles several sessions' worth of previously uncommitted work: map
categories with Domination/Conquest/King of the Hill mode stubs and a
server-list mode filter; a procedurally-drawn character-creation screen
replacing the old callsign-only PROFILE overlay; the on-foot groundwork
(walkable station hub, ship interior, character controller) plus the RAM
currency backend; and today's addition, an in-ship Helldivers-2-style
navigation table that QUICK PLAY's queue/connect flow and the Belters/
Military hub travel now live behind, with hub-and-ship return paths and a
context-aware pause menu usable both in-match and inside the ship.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 12:30:22 -04:00

48 KiB
Raw Blame History

Spacewar

2D multiplayer space shooter inspired by Subspace Continuum. Built in Godot 4.7 (GDScript). Target platform: Steam Deck + PC. Two human factions (Inner Sphere Navy, Outer Rim Collective) fight for galaxy control in casual (25v25, bots fill to 7v7 minimum) matches — ranked is removed until it's real, see Current Tasks.

Design Docs (overview/)

File Contents
overview.md Concept, core loop, game flow, match sizes
racesclasses.md 2 chosen factions, 3 ship classes each
multiplayer.md Maps, game modes (4 casual categories exist as GameMode subclasses — Team Deathmatch is real, Domination/Conquest/King of the Hill are scoring stubs, see CLAUDE.md item 27), galaxy war meta
bots.md Bot fill rules for casual matches
tech.md Engine, networking architecture, checklist
money.md Monetization strategy
structure.md Project file tree, scene graph, input map
networking_implementation_plan.md Historical — the phased plan that took the project from zero networking to item 6/16's real implementation; all phases done
sprite-tileset-brief.md Art-request brief for ship/tileset needs, derived from racesclasses.md
chat.md Original chat-system spec (implemented, item 8)
map1.md Original match-structure spec — pre-match countdown, timer, kills/deaths, scoreboard, winner banner, persistent stats (implemented, item 26)
match_framework_progress.md Implementation checklist for item 26 — historical, all boxes checked
issue.md Dev notes (first Godot project, UI built in code, Steam Deck target)

Matchmaking API (matchmaking-api/, separate FastAPI + Postgres service, own README.md) is a second codebase alongside the Godot project — see its README for how to run/extend it.

Completed

  1. Server browser — superseded by item 14's menu/server_select.gd

  2. Main menu CASUAL/RANKED tiles — superseded by item 14's HL2-style nav menu

  3. In-game pause menu — ESC / controller Start button toggles overlay; game keeps running; RESUME, SETTINGS (stub), SELECT TEAM (live team swap — see item 12), QUIT TO MENU, QUIT TO DESKTOP

  4. Team & ship selection — shows on world load before player spawns; 2 races randomly offered per match, decided once by the server so every player sees the same pair; ship grid with sprites; player spawns with chosen ship after selection

  5. Real race art wired in — Terran Republic, Mechanos Sovereignty, and Vorg Swarm each have their own 5-ship roster (Interceptor/Gunship/Bomber/Support/Heavy) with real sprites cropped from uploaded concept sheets (assets/images/ships/<race>/); placeholder example_ships removed

  6. Multiplayer networking (movement + combat) — ENet authoritative server; networked ship spawning with client-side prediction + server reconciliation for movement; server-authoritative bullets, health, death/respawn all broadcast to every peer (see overview/tech.md)

  7. Matchmaking API + client wiring — FastAPI + Postgres service (matchmaking-api/) for casual/ranked queueing; main menu CASUAL/RANKED buttons queue via the API, poll for a match, then connect to the assigned server. Currently only one dev server is registered (auto-seeded on API startup) — see Current Tasks

  8. In-game chat — T focuses "all" chat (whole match, both teams), Y focuses "team" chat (peers sharing the sender's race, since race doubles as team); Enter sends, Esc cancels; last 10 messages shown bottom-left, WoW-style translucent log + input line (chat/chat_box.gd); server-relayed and team-filtered via ChatManager autoload (autoload/chat_manager.gd)

  9. Bot fill for casual — each of the match's 2 offered races is kept at a minimum of GameConfig.bot_min_team_size (7) total humans+bots; bots spawn/despawn reactively as players join/leave (bots/bot_manager.gd, server-authoritative autoload). Bots always fly their race's fighter/Interceptor (race.ships[0]) and use negative peer_ids, which lets them ride every existing networked-ship system (spawning, loadout replication, position/health sync, bullet attribution) with zero special-casing. AI (bots/bot_ai.gd, class_name BotAI) is a simple seek-nearest-enemy-and-shoot brain, deliberately isolated from ship networking code so future tuning/behavior changes stay contained to that one file. See bots.md. Casual matchmaking now forms with just 1 real player queued (bots pad the rest) — see matchmaking-api/README.md.

  10. Borderless fullscreen, no UI/world scaling — launches at the real screen resolution with stretch mode disabled (1 game pixel = 1 screen pixel), instead of scaling the Steam-Deck-matched 1280×800 design canvas up to fill PC monitors (which looked zoomed in). PC monitors now just reveal more world/HUD instead. Every menu screen (main_menu.gd, team_select.gd, chat/chat_box.gd) was reworked to position itself via anchors relative to the real window instead of hardcoded 1280×800 pixel coordinates — see structure.md's Display section, including a real push_opposite_anchor footgun hit along the way. menu/server_browser.gd was NOT updated at the time (still hardcoded, but unreachable in the live flow) — it has since been deleted, see item 14.

  11. Player list HUD — top-left roster of every connected player, reactive to PlayerRegistry's loadout_updated/player_removed signals rather than polled (hud/player_list.gd, added to world.tscn). White entries are the local player's team (same race), yellow are the enemy team; bots (negative peer_id) get a trailing " (b)". Teammates are sorted to the top.

  12. Select Team in pause menu (live team swap) — pause menu's SELECT TEAM reopens TeamSelect mid-match instead of only offering it once pre-spawn; re-picking a race/ship reuses the existing PlayerRegistry.submit_local_loadoutloadout_updatedShip._init_player/_server_respawn path, so the swap gets a fresh respawn + invincibility window for free. GameConfig.team_select_focused (mirrors chat_focused) blocks ship input while the picker is open, and Esc cancels back out without swapping (only once TeamSelect has already been used to reopen — the original mandatory pre-spawn pick still can't be cancelled). Fixed a bot-fill gap this surfaced: PlayerRegistry.race_changed now fires alongside loadout_updated so BotManager rebalances the race the player left, not just the one they joined.

  13. Live headcounts + roster + team-full lock on the race-selection screen — each race box on TeamSelect's "CHOOSE YOUR FACTION" screen now shows a live human player count and the roster of names below it, reactive to PlayerRegistry.loadout_updated/player_removed (bots excluded from both — they're padding, not a player's actual choice). If one race has more than 2 more humans than the other, the larger side is disabled and reads "TEAM FULL" (except for a player already on that race, so reopening the picker to pick a different ship never locks you out of your own team). This is one shared code path (_show_race_selection()/_refresh_race_boxes()), so it applies identically to the initial pre-spawn pick and to item 12's mid-match SELECT TEAM reopen — no separate implementation needed for the two.

  14. HL2-style main menu + real server selectmain_menu.gd rebuilt as a plain white-on-space vertical nav (QUICK PLAY, SERVER SELECT, OPTIONS stub, PROFILE, QUIT) instead of the old big CASUAL/RANKED tiles; RANKED is gone entirely (not just disabled) until ranked matchmaking is real. Top-right shows callsign/rank/level (rank/level still placeholders, same as before). Callsign entry moved into a PROFILE overlay, auto-opened the first time QUICK PLAY is pressed with no callsign set. New menu/server_select.gd/.tscn replaces the retired menu/server_browser.gd — lists real servers from the matchmaking API's GET /servers, lets the player pick one and connect directly via NetworkManager.join_server (race/ship selection still happens in-world via TeamSelect, same as the matchmaking-queue path). MatchmakingClient.list_servers() added as the public wrapper other screens should use for this — don't call _request directly from outside the autoload.

  15. Ranked mode removed backend-side + server population/ping — item 14 already dropped ranked from the menu UI, but the matchmaking API still had a Mode.ranked queue path (MMR-sorted matching, RANKED_TEAM_SIZE) with no client ever hitting it; that's now deleted (app/models.py/config.py/queue_manager.py/schemas.py in matchmaking-api/) — Mode only has casual. Note this is distinct from the CADET/PILOT/.../LEGEND rank-tier display system (Player.mmr, /ranks, /stats) backing the main menu's profile badge, which stays (still placeholder-driven, see Current Tasks). GameServer gained player_count/max_players columns, returned by GET /servers and settable via POST /servers/register; API startup now seeds 3 demo servers (127.0.0.1:7777/7778/7779, player_count 40/20/10 of 50) instead of one. menu/server_select.gd shows these as DB-fallback PLAYERS/PING columns, then actively overrides them per-row with a live probe: NetworkManager now runs a UDPServer on game-port+10000 (_start_query_responder) that answers a raw "SPACEWAR_PING" datagram with live PlayerRegistry.players.size()/MAX_PLAYERS, separate from the ENet game port since ENet won't answer arbitrary UDP itself — same game-port/query-port split classic server browsers use (offset is large, not +1, since local dev runs servers on sequential ports and a +1 query port would alias onto the next server's actual game port). Only :7777 is realistically reachable without running extra local servers, so the other two demo rows show DB numbers with ping timing out until real servers register there. Post-connect, hud/ping_display.gd shows the local client's live RTT top-right using ENet's own ENetPacketPeer.get_statistic(PEER_ROUND_TRIP_TIME) on peer 1 (the server) — no custom protocol needed once already connected, unlike the pre-connect server-select probe.

  16. Real game-server pool (self-registration + heartbeat + stale sweep)NetworkManager.host_server() now calls the new MatchmakingClient.register_server() once on boot and then every HEARTBEAT_INTERVAL (8s) via a Timer, reporting live PlayerRegistry.players.size()/MAX_PLAYERS — item 15's demo-seeded rows are no longer the only thing populating GET /servers; a real server registering on the same (ip, port, mode) as a demo row just takes it over in place, no special-casing needed. --server-ip= is a new cmdline arg for the IP a hosted server advertises (defaults to loopback for local dev — see the reachability note already in matchmaking-api/README.md about DEV_SEED_SERVER_IP, same constraint applies here). API-side, POST /servers/register now computes status from player_count/max_players (full vs available) instead of always writing available, and a new background loop (sweep_stale_servers in app/routers/servers.py, run every 5s from main.py's lifespan) marks any server offline once its last_heartbeat exceeds server_stale_seconds (20s) — catches a crashed/killed server that never got to deregister cleanly, so a dead server doesn't sit in the list looking joinable forever. menu/server_select.gd's connect button now also blocks (with a message) on status == "full", not just "offline".

  17. Race roster replaced (Terran/Mechanos/Vorg → Apex Dynamics/Inner Sphere Navy/Outer Rim Collective)team_select.gd's RACES now points at the 3 factions from overview/racesclasses.md's pivot, each with a 3-ship Fighter/Gunner/Tank roster (Lancet/Pulsar/Sovereign, Patriot/Barrage/Behemoth, Rail-Jack/Scrap-Spitter/Iron-Clad) instead of the old 5-ship Interceptor/Gunship/Bomber/Support/Heavy set; SPEED_BY_ROLE shrunk to match. Art was extracted from 3 user-provided concept sheets (originally dropped at assets/images/ship/, now cropped per-ship into assets/images/ships/apex|isn|orc/ with a source/ copy of each sheet, same convention as the old race folders) — background removed via flood-fill + largest-connected-component matting (plain background for Apex, starfield for ORC, grid-lined UI panels for ISN, each needing different thresholding). ISN and ORC's source art was drawn nose-sideways; both were rotated 90° before saving since this project's ship rotation convention is nose-up at rotation = 0 (Vector2.UP.rotated(rotation) in ship_movement.gd) — Apex's source art was already nose-up. scale values were computed with the same per-role target-on-screen-height normalization the old roster used (Fighter/Gunner/Tank targets reuse the old Interceptor/Gunship/Heavy heights, ~52.5/58.5/82.5px) so ship sizes read consistently across factions. Verified by hosting a real server and screenshotting bot fill flying with the new sprites — no load errors, transparency and nose-up orientation both correct. The old terran/mech/vorg asset folders are left on disk but unreferenced (no code points at them); not deleted since that wasn't asked for.

  18. Engine-flame sprite swap on thrust — 6 of item 17's 9 ships (ISN's Patriot/Barrage/Behemoth, ORC's Rail-Jack/Scrap-Spitter/Iron-Clad) gained a second _flame.png sprite alongside their idle one, cropped from the same concept sheets' flame-frame columns (ISN's sheet has a clean idle/flame pair per ship; ORC's only unambiguous case was Rail-Jack's brighter 3rd-column flame, so Scrap-Spitter/Iron-Clad's flame PNGs are just copies of their idle sprite for now — their sheet's other columns are damage/weapon-fire poses, not more exhaust). Behemoth's flame was composited from two separate sheet crops (clean idle hull + a flame-only crop of just its thrusters) rather than cropped directly, since the sheet's flame frame has a large red shield-ability graphic overlapping the hull that isn't exhaust. Apex's 3 ships (Lancet/Pulsar/Sovereign) have no flame art — their concept sheet never drew a thrust pose — so they're untouched and always show their one sprite. Wired into gameplay via a new optional flame_path per ship in team_select.gd's RACES, threaded through PlayerRegistry (submit_local_loadout/register_bot/the loadout RPCs, new ship_flame_path field, default "") so bots and remote peers all get it too, not just the local owner. ship_movement.gd's _set_thrust_sprite() swaps the Sprite2D texture based on whether the up/down thrust key is currently held, not current speed — this game's movement has no drag, so a first cut keyed off velocity.length() left the flame on long after letting off the gas since a released ship keeps drifting at speed. The owner predicts this locally the instant it samples input (_owner_tick); the server does the same for bots/itself (_server_tick) and broadcasts its authoritative value as a new thrusting bool on the existing _receive_state RPC so every other peer's view of a ship — which never sees raw input, only replicated state — stays in sync too, with no separate RPC needed. Ships with no flame_path (Apex) just skip the check entirely (_flame_texture == null short-circuits).

  19. Apex Dynamics faction removed — cut down to 2 playable races (Inner Sphere Navy, Outer Rim Collective) to simplify the 2D art pipeline. team_select.gd's RACES array lost its Apex Dynamics block (Lancet/Pulsar/Sovereign), with ISN/ORC renumbered to id 1/2. world.gd's decide_offered_races() no longer picks "2 of N" races — with only 2 total, that logic was vestigial, so it now just shuffles the order of both remaining races (kept for spawn-side/left-right variety, since team_select.gd/ship_movement.gd still key off offered[0]/offered[1]). overview/racesclasses.md's faction-ability Rock-Paper-Scissors loop (which structurally needed 3 legs) is rewritten as a single ISN-shield-vs-ORC-mines counter relationship; overview.md/bots.md/structure.md's "2 of 3 races" language updated to reflect both races always being fielded. assets/images/ships/apex/ and assets/sound/ships/apex/ are left on disk but unreferenced, same as the older unused terran/mech/vorg folders from item 17 — not deleted since that wasn't asked for.

  20. Options/Settings screen — max frame-rate cap — diagnosed a "moving forward feels blurry/laggy" report as physics-tick (fixed 60Hz, see ships/ship_movement.gd) vs. display-refresh-rate judder, made far more visible by item's-ago starfield.gdshader switching from a blurry tiled skybox to pixel-crisp procedural stars — the right fix depends on each player's own monitor, so it's now a user-facing setting rather than a hardcoded value. New menu/settings_panel.gd/.tscn (class_name SettingsPanel, extends CanvasLayer, layer = 25 — above PauseMenu's 10 and TeamSelect's 20, same layering convention, so it still renders/receives input on top when opened from inside the already-paused pause menu) offers a wide spread of common refresh rates (30 up to 360, plus Unlimited) that set Engine.max_fps via a new GameConfig.set_max_fps(), persisted to user://settings.cfg (GameConfig._ready() loads and applies it on boot) so the choice survives restarts. One SettingsPanel instance lives in world.tscn (opened by the pause menu's now-live SETTINGS button) and a second is instantiated directly by main_menu.gd (opened by OPTIONS, no longer a stub) — both just read/write the same GameConfig state, so a choice made in one is already in effect if the other is opened afterward. The FPS cap alone turned out to only be a partial fix -- it only removes judder if the chosen rate happens to be a clean multiple of the physics tick (e.g. a 165Hz-monitor player capping to 165 sees worse judder than capping to 60, since 165/60 isn't an integer ratio and every physics update ends up displayed for an uneven number of frames). The actual fix, layered underneath so it helps regardless of which cap a player picks, is Godot's built-in physics interpolation (physics/common/physics_interpolation=true in project.godot), which blends each node's last two 60Hz physics-tick transforms for rendering instead of holding a position static between ticks. Costs no netcode bandwidth (physics_ticks_per_second is untouched, so submit_input/_receive_state's RPC rate in ship_movement.gd doesn't change) — it only smooths what's already being simulated. Needed two explicit reset_physics_interpolation() calls in _apply_respawn() (on the ship, and on its owner's Camera2D child, now kept as _camera instead of a _ready()-local var) so a spawn/respawn's instant position jump snaps cleanly instead of visibly sliding across the map over one interpolation window — a reset on a parent doesn't propagate to children, since each CanvasItem tracks its own transform history independently. Prediction/reconciliation (_reconcile()) and remote-ship snapshot interpolation (_remote_tick()) were deliberately left untouched: both already produce a fresh authoritative-or-predicted position every physics tick, so physics interpolation layers on top for free as an additional render-side smoothing pass, with small reconciliation corrections now smoothed away instead of visibly popping. Tried removing ship.tscn's Camera2D built-in position_smoothing (speed 8.0) next, on a theory that it was fighting physics interpolation (two independent lag/smoothing systems stacked) — made things feel worse, not better, so it was put back (position_smoothing_enabled = true again) rather than keep an unproven regression.

The actual remaining culprit: every one of the 9 in-use ISN/ORC ship textures (18 counting _flame variants, plus both flagships) had mipmaps/generate=false in their .import file, and every ship is drawn shrunk to roughly 20-35% of native texture size on screen (per-role sprite scale in team_select.gd's RACES × GameConfig.ship_scale_factor (0.75) × GameConfig.camera_zoom (0.85)). Minifying a texture that far with no mipmap chain forces the GPU into raw bilinear point-sampling, which visibly shimmers/blurs as the sub-pixel sampling phase shifts every frame during motion — a purely spatial/GPU-sampling artifact with zero relationship to physics tick rate, frame cap, or camera smoothing, which is exactly why none of those changes touched it (and why disabling camera smoothing, which had incidentally been low-pass-filtering the shimmer, made it more visible instead of less). Fixed by flipping mipmaps/generate=falsetrue in all 14 affected .import files under assets/images/ships/isn/ and assets/images/ships/orc/ and letting Godot re-import (godot4 --headless --path . --import --quit regenerates the cached .ctex files with a proper mip chain baked in — needed once after this kind of .import edit, same as the class-cache-rebuild step needed after adding a new class_name, see the headless-testing memory notes).

Even after that, a periodic (roughly every 8s) stutter remained — smooth for a stretch, then a hitch, invisible while sitting still but very visible while panning (a dropped/delayed frame just doesn't register on an unchanging screen). Root cause: autoload/network_manager.gd's hosting heartbeat (HEARTBEAT_INTERVAL = 8.0, re-registers a hosted server with the matchmaking API for as long as it stays up — see item 16) went through MatchmakingClient._request(), which spun up a brand-new HTTPRequest node (and its background thread) per call and queue_free()'d it right after. Every other caller of _request() fires rarely enough (once, or for a short pre-match queueing window) that this per-call churn is fine; the heartbeat is the one endpoint called on a tight, indefinitely-repeating cadence for a hosted server's entire uptime, so it's the one that needed a persistent, reused HTTPRequest instead. Added MatchmakingClient._heartbeat_http (created once in _ready()) and an optional reuse_http param on _request() that register_server() now passes — every other call site is untouched, still short-lived per-call nodes so concurrent unrelated requests (e.g. matchmaking queue polling) can't collide on a shared node mid-flight.

Last remaining piece: the bottom-right nameplate (ship_movement.gd's _nameplate, a top_level Label manually repositioned every _physics_process() tick) stayed juddery/blurry in motion even after everything else smoothed out, because Godot's built-in physics interpolation only covers Node2D/Node3D transforms — Label's base class is Control, which isn't part of that system at all, so it was still snapping at the raw 60Hz tick rate while the ship sprite and camera (both Node2D) rode the engine's interpolation for free. Fixed the same way this codebase already smooths a remote ship's position between snapshots (_remote_tick()'s lerp pattern): added _nameplate_from_pos/_nameplate_to_pos/_nameplate_interp_elapsed, set once per physics tick, consumed by a new _process() that lerps the nameplate's global_position across render frames the same elapsed / _fixed_delta way _remote_tick() does.

Confirmed there was one more variable left: a 165 FPS cap (menu/settings_panel.gd) still stuttered badly in a repeating smooth/bad cycle even after all of the above — not a code bug, a vsync mismatch (the player's monitor isn't natively 165Hz, so Engine.max_fps=165 fights vsync's own throttling, causing an alternating catch-up/backlog pattern). Capping to exactly 60 fixed it completely. Takeaway for anyone hitting this again: physics interpolation + a correct mipmap/heartbeat/nameplate setup makes any cap that actually matches the display look smooth, but the frame-rate-cap dropdown is still a "match your real monitor" knob, not a free "bigger number is smoother" one — 60 is the one value guaranteed to be judder-free regardless of the player's actual display, since it's an exact match to the fixed physics tick.

Added the other half of that same knob: a VSYNC dropdown (Enabled/Adaptive/Disabled, DisplayServer.window_set_vsync_mode()) next to the frame-rate cap in SettingsPanel, backed by a new GameConfig.vsync_mode (persisted the same way as max_fps, both now written together by a shared GameConfig._save_settings()). Not every display/driver combination will land cleanly on Enabled vsync at a given cap the way 60 did here — Adaptive (only syncs when the frame rate would otherwise exceed the display's refresh) and Disabled (no sync, lowest input lag, tearing possible) are the standard fallbacks for a player who's still stuttering at the right cap.

  1. Options/Settings screen expanded — audio, window mode, colorblind mode, key rebinding — item 20 only ever had a frame-rate cap; menu/settings_panel.gd now has four more sections, all following the same pattern (a GameConfig var + setter that applies live and persists to user://settings.cfg, read back by both SettingsPanel instances via _refresh_selected()). Body is now a fixed-size panel + ScrollContainer instead of growing per section, so it still fits Steam Deck's 800px-tall screen. _add_section()/_add_hint()/_add_dropdown()/_add_slider() helpers factor out the repeated per-section boilerplate now that there are 5 sections instead of 2.

    • Audio — the project had zero volume control before this (every sound used a hardcoded volume_db). Added default_bus_layout.tres (Master/Music/SFX, Music and SFX both routed to Master) and pointed autoload/music_manager.gd's player and world.gd's _play_shoot_sound()'s per-shot AudioStreamPlayer2D at their respective buses. GameConfig.master_volume/music_volume/sfx_volume are linear 0..1 (HSlider's native range), converted to dB only in _apply_bus_volume() (0.0 explicitly mutes the bus rather than relying on linear_to_db(0)'s -inf edge case). The volume setters deliberately don't call save_settings() themselves — HSlider.value_changed fires continuously through a drag, and writing the config file to disk on every one of those would reintroduce exactly the per-event hitch item 20 already hunted down once (the matchmaking heartbeat). SettingsPanel applies live on every value_changed and only persists once on the slider's drag_ended.
    • Window modeGameConfig.window_mode (DisplayServer.WindowMode), default WINDOW_MODE_FULLSCREEN matching project.godot's existing boot default (item 10) so nothing changes until a player picks something else. Exclusive Fullscreen and Windowed are the other two options.
    • Colorblind-friendly enemy colorGameConfig.colorblind_mode swaps enemy_color between the existing red (ENEMY_COLOR_DEFAULT) and a high-contrast orange (ENEMY_COLOR_COLORBLIND) that stays distinct from team_color (white, unchanged in both palettes) and from the minimap's own blue self-marker across effectively all forms of color vision deficiency. hud/mini_map.gd had its own separate hardcoded TEAM_COLOR/ENEMY_COLOR consts (white/yellow, inconsistent with the red used everywhere else) — replaced with reads of GameConfig.team_color/enemy_color so the toggle (and the color scheme generally) is consistent across nameplates, the top-left roster, and the minimap instead of just the first two.
    • Key rebinding — keyboard-only (the only non-keyboard binding in the project, toggle_pause's joypad Start button for Steam Deck, is deliberately left alone). GameConfig.KEYBIND_ACTIONS/DEFAULT_KEYCODES mirror project.godot's [input] section, needed because InputMap resets to those compiled-in defaults on every engine boot — GameConfig._apply_single_keybind() reapplies any persisted override on top at _ready(), and only ever erases/re-adds an action's InputEventKey entries specifically (not the whole action), so rebinding e.g. toggle_pause's keyboard key can never silently wipe its separate joypad binding. SettingsPanel's CONTROLS section shows one row per action with a button reading its current key (InputEventKey.as_text_physical_keycode()); clicking it sets _rebinding_action and the panel's own _input() captures the next physical key (Esc cancels instead of binding). New GameConfig.settings_focused flag (same pattern as chat_focused/team_select_focused) gates ship movement input and pause_menu.gd's own Escape-toggles-pause handling while the panel is open — without it, a rebind capture pressing W/A/S/D/Space would also thrust/turn/fire the ship if unpaused behind the panel, and pressing Esc to cancel a rebind would also close the pause menu underneath it.
  2. Spawn "fly in" intro — a ship's first-ever appearance now eases in from a random direction instead of just popping into place. ship_movement.gd's _play_spawn_intro() is purely a cosmetic Sprite2D.position tween (TRANS_EXPO/EASE_OUT — fast off the start, tailing off into the landing spot) layered on top of the already-correct global_position; collision, camera, and every other peer's replicated view of this ship are untouched; the sprite is what everyone (including bots) visibly sees warp in, since _apply_respawn() runs identically on every peer via its call_local RPC. Gated by a new _played_spawn_intro bool that's never reset, so only the ship's true first spawn plays it — a later death/respawn or mid-match team swap (_apply_respawn()'s other two call sites) don't repeat it, matching "when the match starts" rather than every respawn.

  3. Per-role energy system — each ship has an energy pool alongside health, spent on firing: Fighter 100 max / 75 per shot, Gunner 150 max / 25 per shot, Tank 250 max / 50 per shot (GameConfig.ship_max_energy_by_role/ship_bullet_energy_cost_by_role), regenerating at a flat 100-per-2.5s rate (ship_energy_regen_rate) regardless of role. Server-authoritative in ships/ship_movement.gd, same pattern as health — a shot is only fired if the server's copy of the ship has enough energy, and current energy broadcasts to every peer over the existing _receive_state RPC. Role is threaded through PlayerRegistry's loadout (so remote peers and bots all get the right pool/cost, not just the local owner) and bots/bot_manager.gd's bot registration. Shown as a text readout (HUD/EnergyLabel, below the health label) plus a blue center-out mirrored bar top-middle of screen (hud/energy_bar.gd/.tscn).

  4. Capital ships (Flagships) + kill feed + bot personalitiesworld/flagship.gd (class_name Flagship) spawns a 3-ship point-defense formation at each team's spawn marker (world.gd's _spawn_flagships(), called with the same server-decided race_ids/match_seed every peer already uses for map/race setup, so every client builds an identical formation locally with no replication needed). Each flagship independently scans for the nearest enemy ship in GameConfig.flagship_defense_radius and fires a lead-aimed (with jitter, so it can miss) slow missile plus a faster bullet stream, both riding the existing networked BulletSpawner — deters a team from parking on the enemy spawn and farming respawns. 5000 HP, shootable for damage-number feedback but no destroy/respawn logic yet (that's future Flagship Assault mode, see multiplayer.md's ranked-mode ideas). Collision/detection hulls are traced from each sprite's actual alpha channel via Geometry2D.convex_hull, not a bounding box, since the hulls taper sharply at the nose/tail. A ship flying under a flagship's hull ghosts (reuses ship_movement.gd's existing explosion-cover ghost mechanism) rather than colliding. New per-race/role bullet sprites for turret fire live in assets/images/effects/bullets/. hud/mini_map.gd/mini_map_view.gd draw flagships as larger team-colored blips (_update_flagships()), and bots/bot_ai.gd's awareness was tuned to notice them. Alongside this: a server-authoritative kill feed (autoload/kill_feed_manager.gd broadcasts victim/killer name+race on every death from ship_movement.gd's _die(); hud/kill_feed.gd shows the last 4 lines stacked directly above ChatBox's history panel) and bot personalities (bots/bot_personality.gd, class_name BotPersonality — 5 independent 0.0-1.0 traits: aggression/caution/accuracy/reaction/awareness, rolled once per bot-slot by BotManager and kept for the bot's lifetime so a player can learn and counter a specific bot's behavior, see bots.md).

  5. ISN flagship formation mixes in a Stealth Corvette hull — item 24's 3-ship flagship formation was 3 identical flagship_colossus.png copies per faction; ISN's formation is now 2 Colossus + 1 stealth_corvette.png (a new hull processed from a user-supplied render: background matted out via border flood-fill, a disconnected decorative sparkle dropped, rotated 90° to this project's nose-up convention — see assets/images/ships/isn/source/isn_stealth_corvette_obsidian_source.jpeg for the original). team_select.gd's ISN entry gained a flagship_paths array (list of {path, scale}, cycled by formation-slot index in world.gd's _spawn_flagship()); a race with no flagship_paths (ORC, so far) falls through to the original singular flagship_path/flagship_scale for every slot, so this is additive and backward compatible. The Corvette's scale: 0.68 was calibrated against its native resolution (516×1019, notably taller than Colossus's 535×692) so all 3 formation members read at the same on-screen size despite the size mismatch in source art.

  6. Match structure — pre-match countdown, timer, kills/deaths, scoreboard, winner banner, persistent stats, reusable GameMode/map framework (see overview/map1.md) — matches now have real phases instead of ships spawning the instant a peer connects. New MatchManager autoload (autoload/match_manager.gd) runs a server-authoritative PRE_MATCH (30s countdown, ships held invisible/uncollidable via ship_movement.gd's server_hold_for_match_start()/server_release_from_hold() — reuses the existing dead-ship machinery rather than a parallel state, so prediction/reconciliation are untouched; flagships cosmetically tween in from a random offset via flagship.gd's play_creep_in()) → IN_PROGRESS (the active GameMode's 7-minute clock runs, ships released) → POST_MATCH (winner banner, result reported to matchmaking-api) → loops back into a fresh PRE_MATCH forever, matching the always-on server-pool model from item 16 rather than kicking players to the menu. Phase state is replicated with the same "decide once on the server, RPC-broadcast, late joiners pull it" pattern world.gd's decide_offered_races() already established. Win-condition logic lives entirely in a new GameMode abstraction (world/game_modes/game_mode.gd base + team_deathmatch_mode.gd, the only mode implemented so far — most kills when the clock runs out wins, draw on a tie) built identically on every peer off a shared GameConfig.default_game_mode_id constant, so a future mode (multiplayer.md's Flagship Assault/Last Ship Standing/Capture and Hold ideas) is a new subclass plus one factory branch in MatchManager, with zero changes to the timer/scoreboard/banner code around it — none of that ever hardcodes "kills," only ever calling GameMode.get_score_for_team()/get_score_label(). world.gd's hardcoded MAP_SCENE const became a MAPS array + _pick_map() (same static-array convention as TeamSelect.RACES) so a second map is an append, not a rewrite — still only map_01 exists today. New MatchStats autoload (autoload/match_stats.gd) tracks per-match (not persistent) kills/deaths by peer_id, including bots, hooked into the single existing death-detection call site (kill_feed_manager.gd's report_kill()) rather than duplicating it in ship_movement.gd. Three new signal-driven HUD CanvasLayers follow every existing HUD file's autonomous-_build_ui() convention: hud/match_timer.gd (top-center countdown/clock), hud/scoreboard.gd (hold-Tab CS:GO-style two-team panel, new scoreboard input action bound to Tab), hud/match_banner.gd (full-screen winner banner, layer=30 — highest in the project). Banner art (assets/images/banners/) was renamed from opaque UUID filenames to isn_banner.jpeg/orc_banner.jpeg (the third, Apex Dynamics, stays orphaned per item 19's precedent of leaving unused assets on disk) and wired via a new banner_path key on team_select.gd's ISN/ORC RACES entries, same pattern as the existing flagship_path key. Persistent per-player history (total kills, deaths, wins, losses, hours played) is a new matchmaking-api capability: Player gained kills/deaths/hours_played columns, a new POST /matches/report endpoint (app/routers/matches.py) is called once by the hosting server's MatchManager (never a client, same server-only trust model as /servers/register) at POST_MATCH, upserting each real (non-bot) player's totals by callsign via a shared app/crud.py helper also now used by the matchmaking-queue join path; GET /stats/{callsign} returns the 3 new fields alongside the existing mmr/wins/losses. No migrations tooling exists in this API (schema changes just land in models.py for create_all() to pick up on a fresh DB) — a docker compose down -v is needed once to add the new columns to an already-existing local players table, verified end-to-end via a real docker compose up round-trip during implementation.

  7. Map categories + 3 new GameMode stubs (Domination, Conquest, King of the Hill) — maps now declare which game modes they support, the way CS's de_/cs_ prefixes tie a map to a mode, except a map can list more than one category rather than being locked to exactly one. world.gd's MAPS entries gained a "categories" array; World.pick_map() (static, public) filters MAPS down to whichever list a given mode id and picks the first match, falling back to the full list if nothing matches (documented as not randomized among ties — every caller runs this independently with no shared seed, unlike _spawn_flagships()'s _match_seed, so a random pick here would desync which map each peer loads the moment a second candidate exists; real map rotation needs that seed-sync treatment first). Three new GameMode subclasses (world/game_modes/domination_mode.gd/conquest_mode.gd/king_of_the_hill_mode.gd) round out MatchManager._create_mode()'s factory alongside team_deathmatch_mode.gd — each only overrides get_mode_name() for now and shares a new GameMode._kills_based_win_condition() helper (factored out of TeamDeathmatchMode.check_win_condition(), which now just calls it) for scoring, since none of the three have real zone/capture-point mechanics yet. overview/multiplayer.md's casual mode-ideas table was renamed to match this session's chosen names (Domination/Conquest/King of the Hill), replacing the old Sector Control/Annihilation/Base Assault/Convoy Escort ideas that had no code behind them either way. map_01 only lists "team_deathmatch" in its categories — Domination/Conquest/King of the Hill exist as GameMode code but no map is actually built/tuned for them yet, so map_01 doesn't claim to support modes it can't really run.

    Server list now reports the real game mode + mapmenu/server_select.gd was previously showing only the matchmaking queue mode ("CASUAL", the only value Mode has since item 15) with no indication of what's actually being played. GameServer (matchmaking-api) gained game_mode/map_name string columns (plain strings, not a Mode-style enum — the API has no reason to know the client's GameMode id catalog), set via new fields on POST /servers/register's ServerRegisterRequest and returned by GET /servers; NetworkManager._register_with_matchmaking_api() now calls the static World.pick_map(GameConfig.default_game_mode_id) to get these before every heartbeat — safe to call before world.tscn even loads (the heartbeat starts on server boot) since it's a pure function of shared static data, same "decide independently, same result on every caller" pattern as TeamSelect.RACES. world.gd gained a class_name World so this static call is reachable from NetworkManager. Each server row now reads e.g. "TEAM DEATHMATCH · Sector Alpha 127.0.0.1:7777" (_format_mode_name() mirrors GameMode.get_mode_name()'s formatting so the two never spell a mode differently). Verified end-to-end: rebuilt the local matchmaking-api DB (docker compose down -v && up -d --build, needed for the new columns per this API's no-migrations convention — see item 26/matchmaking-api/README.md), hosted a real headless server, and confirmed GET /servers showed game_mode: "team_deathmatch", map_name: "Sector Alpha" for the live row.

    Game mode became its own toggleable filter, not just row textserver_select.gd gained an "ALL MODES" + one-per-GameConfig.GAME_MODE_IDS toggle bar (radio-style via a ButtonGroup, same pattern the server rows below already use) above the list; picking one narrows the rendered rows to that game_mode without re-hitting the API. GameConfig.GAME_MODE_IDS is a new canonical ordered array (["team_deathmatch", "domination", "conquest", "king_of_the_hill"]) backing both this filter bar and default_game_mode_id's valid values. Required splitting what was one _refresh_servers() (fetch + render) into _refresh_servers() (network fetch only, sets _all_servers) and _apply_filter() (narrows to _selected_category, rebuilds rows into _servers — the array rows/ping-probes/CONNECT actually index into) so toggling a filter doesn't need a fresh network round-trip.

  8. Character creation screen (Stardew-Valley-style callsign + portrait picker) — the main menu's PROFILE overlay (item 14) was just a callsign LineEdit; it now also offers a portrait pick, over the existing space-themed starfield background (main_menu.gd's _add_bg(), unchanged, already reused here). No portrait art exists in the project (no assets/images/portraits/ or similar), so portraits are procedurally drawn rather than sourced as new images: new menu/pilot_portrait.gd (class_name PilotPortrait, extends Control) _draw()s a simple pilot bust (shoulders + helmet + visor) from a PRESETS array of grayscale suit shades paired with a colored visor accent (Ash/Slate/Steel/Iron/Fog/Graphite) — kept monochrome/gray per this feature's art direction, with the visor as the only pop of color per preset. _add_profile_overlay()'s panel grew (400×240 → 420×450) to fit a centered portrait frame flanked by </> Buttons (_on_portrait_cycle(), cycling PilotPortrait.preset_index which wraps via wrapi()) above the existing callsign field; title changed from "PROFILE" to "CREATE YOUR PILOT". New GameConfig.player_portrait_index: int stores the choice with the same lifetime as the existing (not-persisted-to-disk, resets on relaunch) player_name — only written on SAVE, matching the callsign's existing commit-on-save behavior rather than live-updating as the player cycles. The chosen portrait is also now shown as a small icon in the main menu's top-right badge (_badge_portrait, refreshed in _refresh_profile_badge()) next to the callsign/rank text, so the pick is visible outside the creation screen too, not just while editing it.

  9. On-foot station hub — first sprite import + walkable room (new gameplay mode, groundwork only) — kicks off a Stardew-Valley-style on-foot mode alongside the ship combat, using 4 AI-generated reference sheets dropped at assets/images/worldsprites/orc/ (a multi-NPC character roster, a messy "concept + swatches" corridor sheet, a clean labeled tileset reference sheet, and a furniture/props sheet). No godot4 binary was assumed available per prior session notes, but this sandbox does have one at the godot4-aliased path — used here to actually headless-import and screenshot-verify everything below, not just hand-write .import files blind. Individual sprites were hand-cropped out of the sheets with ImageMagick (no PIL/pip in this sandbox) since none of the 4 sheets are uniform game-ready spritesheets — grid boundaries were found by overlaying a labeled pixel grid and reading it back with the Read tool, iterating per-crop; backgrounds matted to transparent via corner-seeded flood fill (-fuzz/-draw "alpha X,Y floodfill"), same technique as the existing ship sprites. New assets: assets/images/worldsprites/character/soldier_{idle,walk}.png (one NPC variant's front-idle + side-walking pose — the sheet has no full 4-directional walk cycle, just these two poses per outfit, so on_foot_character.gd swaps between them by movement state rather than animating frames, and flips Sprite2D.flip_h for left/right, mirroring ship_movement.gd's thrust-sprite-swap pattern from item 18); assets/images/worldtiles/station_{floor,walls,door}.png (3 floor variants + 2 wall variants cropped to a uniform 72×72 grid from the clean labeled sheet, plus one door graphic used as a decorative Sprite2D, not a tile); assets/images/worldsprites/furniture/{locker,cabinet,plant,server_rack}.png. Each folder keeps a source/ copy of its origin sheet, same convention as the race art in item 17. New world/station_tileset.tres (2 TileSetAtlasSources, tile_size = Vector2i(72, 72)) follows world/world_tileset.tres's existing walls+asteroids split pattern. New world/levels/station_hub.tscn + station_hub.gd (class_name StationHub) procedurally builds a 12×9 room via TileMapLayer.set_cell() in _ready() (hand-authoring the raw tile_data PackedInt32Array format was judged too risky to get right blind, so the layout is code-generated instead of painted) with a door-sized gap in the wall ring, spawns StaticBody2D wall colliders per used cell (same "tiles have no collision shapes of their own" approach as world.gd's ship-map collider builder, but a separate implementation local to this scene, not a shared one) and places the 4 furniture sprites as static decoration. New world/levels/on_foot_character.gd (class_name OnFootCharacter, CharacterBody2D) is a 4-directional top-down walker (reuses the existing move_up/down/left/right input actions literally instead of the ships' rotate+thrust scheme) with idle/walk sprite swapping and a Camera2D using the same make_current() fix from the networking memory notes. Verified end-to-end, not just import-clean: launched the real scene headlessly under a throwaway Xvfb display (:133, distinct from the user's actual running editor/display — left both untouched), screenshotted the room (floor variety, both wall types, door gap, all 4 furniture pieces, and the player all render correctly), then drove movement via a temporary HubAutopilot autoload (removed after) simulating a held move_right and confirmed the walk sprite/camera-follow render correctly and that the character stops cleanly at the rock wall's collider instead of clipping through. This is groundwork only — no main-menu entry point, no NPC interaction/dialogue, no second room/transition, and the character roster/tileset/furniture sheets still have far more content un-cropped than used (only 1 of ~10 character variants, 5 of ~40 tileset tiles, 4 of ~30 furniture items) — see Current Tasks.

Current Tasks

  • Asteroids and environment hazards — done, undocumented until now: world.gd's collider builder gives the map's Asteroids TileMapLayer round hitboxes in an environment_hazard group distinct from environment_wall, and deals impact damage (GameConfig.ship_wall_damage) on top of the bounce every wall/asteroid tile already causes
  • Sound effects — shoot (per-ship, world.gd's _play_shoot_sound()) and menu music (autoload/music_manager.gd) exist; thrust, explosion, and UI-click sounds are still missing
  • Graphics quality presets / resolution scale in Options (audio, window mode, colorblind, and key rebinding are now done — see item 21)
  • Real rank/level backend — main menu's top-right badge and RANK_DATA are still placeholders (CURRENT_RANK/CURRENT_LEVEL constants in main_menu.gd)
  • Real scoring for Domination/Conquest/King of the Hill (see item 27) — all 3 exist as GameMode subclasses with map-category tags wired up, but still score by kills like Team Deathmatch until each grows its own zone/point/capture mechanic
  • Galaxy war meta / sector control for casual (see multiplayer.md) — ties most naturally to Domination once it has real per-sector scoring, see above
  • Real account-linked identity — GodotSteam auth + VAC still not started; callsign is the only player identity today
  • Lag compensation (basic rewind) — matters once testing moves beyond localhost
  • On-foot station hub (see item 29) — currently a standalone unlinked scene (world/levels/station_hub.tscn) with one hand-built room; needs a main-menu entry point, decide whether it's multiplayer (networked like ship combat) or single-player/local, NPC interaction, and cropping more of the still-mostly-unused character/tileset/furniture sheets