# Spacewar

2D multiplayer space shooter inspired by Subspace Continuum. Built in **Godot 4.7** (GDScript). Target platform: Steam Deck + PC. Three races fight for galaxy control across casual (25v25) and ranked (5v5) modes.

## Design Docs (`overview/`)

| File | Contents |
|------|----------|
| `overview.md` | Concept, core loop, game flow, match sizes |
| `racesclasses.md` | 3 chosen races, 5 ship classes each |
| `multiplayer.md` | Maps, game modes, 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 |

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_loadout` → `loadout_updated` → `Ship._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 select** — `main_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.

## Current Tasks

- [ ] Asteroids and environment hazards
- [ ] Sound effects (thrust, shoot, explosion, UI)
- [ ] Options screen — currently just a disabled stub button on the main menu
- [ ] 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`)
- [ ] Galaxy war meta / sector control for casual (see `multiplayer.md`)
- [ ] 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
