Feature work (CLAUDE.md items 17-18): - Race roster art rework (Apex/ISN/ORC) plus per-ship engine-flame sprites that swap in on thrust - Floating damage-number VFX, per-ship nameplates, energy bar rework - Background music manager and per-race ship SFX Bugfixes/tuning from live testing: - No friendly fire: bullets stop on a teammate's hull but deal no damage (bullet.gd), decided by race via PlayerRegistry - Ships no longer physically block each other (own collision layer, ship.tscn/bullet.tscn) -- fixes ramming/parking trolling and the stuck-on- corpse bug after a kill - Explosions ghost any ship's sprite passing through instead of blocking it (effects/explosion.gd's GhostArea) - Fixed two "flushing physics queries" crashes from the above: explosion spawn and death/respawn collision toggling are now deferred (ship_movement.gd) - Dead ships stop blocking bullets during their respawn delay - Bots fly a mixed roster (1 Tank + 2 Gunner + rest Fighter per team) instead of always the Fighter (bot_manager.gd) - Camera zoom knob, larger/longer-lived damage numbers and nameplate text, red (not yellow) enemy name color for better contrast Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
15 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).
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