Files
client/CLAUDE.md
T
anekdotin 2f6607af8c Add engine flames, damage numbers, nameplates, music, and combat/collision fixes
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>
2026-07-15 23:55:18 -04:00

15 KiB
Raw Blame History

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

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