Drop Apex Dynamics from the playable roster (2 races now: Inner Sphere Navy, Outer Rim Collective) to simplify the 2D art pipeline; World's race-offer logic just shuffles both remaining races instead of picking 2 of N. Also fix bullets using a constant muzzle velocity instead of adding the shooter's own velocity, and shrink the bullet hitbox/sprite. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
16 KiB
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
-
Server browser— superseded by item 14'smenu/server_select.gd -
Main menu CASUAL/RANKED tiles— superseded by item 14's HL2-style nav menu -
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
-
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
-
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>/); placeholderexample_shipsremoved -
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) -
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 -
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 viaChatManagerautoload (autoload/chat_manager.gd) -
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. Seebots.md. Casual matchmaking now forms with just 1 real player queued (bots pad the rest) — seematchmaking-api/README.md. -
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 — seestructure.md's Display section, including a realpush_opposite_anchorfootgun hit along the way.menu/server_browser.gdwas NOT updated at the time (still hardcoded, but unreachable in the live flow) — it has since been deleted, see item 14. -
Player list HUD — top-left roster of every connected player, reactive to
PlayerRegistry'sloadout_updated/player_removedsignals rather than polled (hud/player_list.gd, added toworld.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. -
Select Team in pause menu (live team swap) — pause menu's SELECT TEAM reopens
TeamSelectmid-match instead of only offering it once pre-spawn; re-picking a race/ship reuses the existingPlayerRegistry.submit_local_loadout→loadout_updated→Ship._init_player/_server_respawnpath, so the swap gets a fresh respawn + invincibility window for free.GameConfig.team_select_focused(mirrorschat_focused) blocks ship input while the picker is open, and Esc cancels back out without swapping (only onceTeamSelecthas 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_changednow fires alongsideloadout_updatedsoBotManagerrebalances the race the player left, not just the one they joined. -
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 toPlayerRegistry.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. -
HL2-style main menu + real server select —
main_menu.gdrebuilt 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. Newmenu/server_select.gd/.tscnreplaces the retiredmenu/server_browser.gd— lists real servers from the matchmaking API'sGET /servers, lets the player pick one and connect directly viaNetworkManager.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_requestdirectly from outside the autoload. -
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.rankedqueue 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.pyinmatchmaking-api/) —Modeonly hascasual. 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).GameServergainedplayer_count/max_playerscolumns, returned byGET /serversand settable viaPOST /servers/register; API startup now seeds 3 demo servers (127.0.0.1:7777/7778/7779,player_count40/20/10 of 50) instead of one.menu/server_select.gdshows these as DB-fallback PLAYERS/PING columns, then actively overrides them per-row with a live probe:NetworkManagernow runs aUDPServeron game-port+10000 (_start_query_responder) that answers a raw"SPACEWAR_PING"datagram with livePlayerRegistry.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:7777is 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.gdshows the local client's live RTT top-right using ENet's ownENetPacketPeer.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. -
Real game-server pool (self-registration + heartbeat + stale sweep) —
NetworkManager.host_server()now calls the newMatchmakingClient.register_server()once on boot and then everyHEARTBEAT_INTERVAL(8s) via aTimer, reporting livePlayerRegistry.players.size()/MAX_PLAYERS— item 15's demo-seeded rows are no longer the only thing populatingGET /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 inmatchmaking-api/README.mdaboutDEV_SEED_SERVER_IP, same constraint applies here). API-side,POST /servers/registernow computesstatusfromplayer_count/max_players(fullvsavailable) instead of always writingavailable, and a new background loop (sweep_stale_serversinapp/routers/servers.py, run every 5s frommain.py's lifespan) marks any serverofflineonce itslast_heartbeatexceedsserver_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) onstatus == "full", not just"offline". -
Race roster replaced (Terran/Mechanos/Vorg → Apex Dynamics/Inner Sphere Navy/Outer Rim Collective) —
team_select.gd'sRACESnow points at the 3 factions fromoverview/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_ROLEshrunk to match. Art was extracted from 3 user-provided concept sheets (originally dropped atassets/images/ship/, now cropped per-ship intoassets/images/ships/apex|isn|orc/with asource/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 atrotation = 0(Vector2.UP.rotated(rotation)inship_movement.gd) — Apex's source art was already nose-up.scalevalues 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 oldterran/mech/vorgasset folders are left on disk but unreferenced (no code points at them); not deleted since that wasn't asked for. -
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.pngsprite 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 optionalflame_pathper ship inteam_select.gd'sRACES, threaded throughPlayerRegistry(submit_local_loadout/register_bot/the loadout RPCs, newship_flame_pathfield, default"") so bots and remote peers all get it too, not just the local owner.ship_movement.gd's_set_thrust_sprite()swaps theSprite2Dtexture 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 offvelocity.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 newthrustingbool on the existing_receive_stateRPC 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 noflame_path(Apex) just skip the check entirely (_flame_texture == nullshort-circuits). -
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'sRACESarray lost its Apex Dynamics block (Lancet/Pulsar/Sovereign), with ISN/ORC renumbered toid1/2.world.gd'sdecide_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, sinceteam_select.gd/ship_movement.gdstill key offoffered[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/andassets/sound/ships/apex/are left on disk but unreferenced, same as the older unusedterran/mech/vorgfolders 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_LEVELconstants inmain_menu.gd) - Galaxy war meta / sector control for casual (see
multiplayer.md) - Real account-linked identity — GodotSteam auth + VAC still not started;
callsignis the only player identity today - Lag compensation (basic rewind) — matters once testing moves beyond localhost