Files
anekdotin be5c41f82f Add match structure (countdown, timer, scoreboard, banner, persistent stats) and capital-ship fleet polish
Match phases (autoload/match_manager.gd, world/game_modes/):
- New server-authoritative MatchManager loops PRE_MATCH (30s countdown,
  ships held invisible/uncollidable, flagships creep into formation) ->
  IN_PROGRESS (active GameMode's clock runs) -> POST_MATCH (winner
  banner, result reported to matchmaking-api) -> back to a fresh
  PRE_MATCH forever, matching the always-on server-pool model instead
  of kicking players to the menu at match end.
- Win-condition logic lives in a new GameMode abstraction (game_mode.gd
  base + team_deathmatch_mode.gd, the only mode so far) so a future
  mode is a new subclass plus one factory branch, no timer/scoreboard/
  banner code changes needed.
- Three new HUD pieces: match_timer.gd (countdown/clock), scoreboard.gd
  (hold-Tab two-team panel), match_banner.gd (winner banner).
- New MatchStats autoload tracks per-match kills/deaths by peer_id
  (including bots), hooked into kill_feed_manager's existing
  report_kill() call site.

Persistent stats (matchmaking-api/):
- Player gains kills/deaths/hours_played columns; new
  POST /matches/report endpoint (server-only, called once at
  POST_MATCH) upserts each real player's totals by callsign via a
  shared app/crud.py helper also used by the matchmaking-queue join
  path. GET /stats/{callsign} returns the new fields alongside mmr/
  wins/losses.

Capital-ship fleet polish (world/flagship.gd, world/world.gd,
ships/ship_movement.gd, autoload/game_config.gd):
- Flagship formations are now a clean vertical line (no per-ship
  position/rotation jitter) so play_creep_in()'s rigid-group tween
  reads as one disciplined fleet arriving together, rising from
  directly below (not a random compass direction) over the full 30s
  countdown.
- Fixed a real bug where the creep-in tween only ever played on the
  server -- MatchManager._run_pre_match() called straight into World,
  server-only code a remote client's own process never runs, leaving
  their flagships static all match. World now triggers it off
  MatchManager.phase_changed instead, which fires identically on every
  peer.
- Fixed a second bug (only reachable on a fresh server boot's very
  first spawn): a phase==PRE_MATCH check that's true even before the
  match loop has genuinely started that phase for the first time fired
  play_creep_in() with a bogus zero-duration tween, corrupting the
  target the real 30s tween read moments later -- flagships would
  settle 4000 units off from their intended formation slot and fire
  from there instead. Guarded on get_remaining_seconds() > 0 too.
- The held ship's camera now actively tracks its own team's flagship
  centroid every tick during the countdown (position_smoothing
  disabled for the hold, since it fights a manually-driven target and
  was the reason the fleet read as invisible) instead of sitting fixed
  and wide-angle; local offset/zoom/smoothing are explicitly reset on
  release so control handback doesn't inherit a stale camera transform.
- _apply_pre_match_hold()/_apply_respawn() now set _dead/
  _held_for_pre_match inside the RPC itself, not just in the
  server-only caller -- those flags never reached remote clients
  before, so WASD wasn't actually blocked for them during the hold.
- Ships now launch from a narrow point directly beneath their own
  flagship formation instead of a full-circle scatter around the spawn
  marker (which could land a spawn behind/inside a hull). Bumped
  flagship_defense_radius so it still comfortably reaches a player who
  flies a straight line to the enemy side without correcting for that
  new offset.

ISN gets a Stealth Corvette hull mixed into its flagship formation
(assets/images/ships/isn/), banner art renamed off opaque UUID
filenames to isn_banner.jpeg/orc_banner.jpeg.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 11:40:15 -04:00

19 KiB
Raw Permalink Blame History

Project Structure

File Tree

spacewar/                        ← repo root
└── spacewar/                    ← Godot project root (open this in Godot editor)
    ├── project.godot
    ├── autoload/
    │   ├── game_config.gd         ← match-wide tuning constants, input-focus flags, persisted user settings (see GameConfig table below)
    │   ├── network_manager.gd     ← owns the ENetMultiplayerPeer; host_server()/join_server(); server self-registration+heartbeat with matchmaking-api; UDP query responder for pre-connect server-select pings
    │   ├── player_registry.gd     ← per-peer loadout registry (name/race/ship/role/flame/sound paths), keyed by peer_id; server-relayed to every peer
    │   ├── matchmaking_client.gd  ← HTTP wrapper around matchmaking-api (queue, list_servers, register_server); don't call _request() directly from outside this file
    │   ├── chat_manager.gd        ← server-relayed all/team chat
    │   ├── kill_feed_manager.gd   ← server-authoritative kill-feed broadcast (victim/killer name+race per death); also feeds MatchStats
    │   ├── match_stats.gd         ← per-match (resets every loop) kills/deaths by peer_id, incl. bots; hooked from kill_feed_manager.gd
    │   ├── match_manager.gd       ← server-authoritative match-phase state machine (PRE_MATCH/IN_PROGRESS/POST_MATCH, loops forever); owns the active GameMode
    │   └── music_manager.gd       ← menu background music player, routed through the Music audio bus
    ├── menu/
    │   ├── main_menu.tscn/gd       ← entry point / main scene; HL2-style vertical nav (QUICK PLAY, SERVER SELECT, OPTIONS, PROFILE, QUIT)
    │   ├── server_select.tscn/gd   ← server list UI, backed by matchmaking-api's GET /servers + a live UDP ping probe
    │   ├── pause_menu.tscn/gd      ← in-game pause overlay (ESC / Start button)
    │   ├── settings_panel.tscn/gd  ← Options screen; one instance shared by main menu (OPTIONS) and pause menu (SETTINGS); audio/frame-rate/vsync/window-mode/colorblind/key-rebind sections
    │   └── team_select.tscn/gd     ← faction + ship selection overlay (shown on world load and on pause-menu SELECT TEAM)
    ├── ships/
    │   ├── ship.tscn             ← player ship scene
    │   ├── ship_movement.gd      ← player ship logic (movement, prediction/reconciliation, health/energy, nameplate interpolation)
    │   └── bullet.tscn/gd        ← projectile
    ├── chat/
    │   └── chat_box.tscn/gd      ← WoW-style chat overlay (T=all, Y=team), added to world.tscn
    ├── hud/
    │   ├── player_list.tscn/gd    ← top-left player roster (white=team, yellow/orange=enemy per colorblind mode, "(b)"=bot)
    │   ├── kill_feed.tscn/gd      ← last 4 deaths, stacked above ChatBox's history panel
    │   ├── mini_map.tscn/gd       ← polls game state (hazards, players, flagships), pokes MiniMapView, calls queue_redraw()
    │   ├── mini_map_view.gd       ← pure Control _draw() surface for the minimap (split out since CanvasLayer can't override _draw())
    │   ├── ping_display.tscn/gd   ← top-right live RTT via ENetPacketPeer.get_statistic(PEER_ROUND_TRIP_TIME)
    │   ├── energy_bar.tscn/gd     ← blue center-out mirrored bar; also instanced for the health bar (top of world.tscn)
    │   ├── match_timer.tscn/gd    ← top-center match clock (PRE_MATCH countdown / IN_PROGRESS 7:00 countdown), driven by MatchManager
    │   ├── scoreboard.tscn/gd     ← hold-Tab CS:GO-style scoreboard, two team panels (name/role/K/D), driven by MatchStats + the active GameMode
    │   └── match_banner.tscn/gd   ← full-screen winner banner (ISN/ORC art + "<RACE> WINS"/"DRAW"), shown during MatchManager's POST_MATCH phase
    ├── bots/
    │   ├── bot_manager.gd         ← autoload; server-only bot fill (join/leave reconciliation)
    │   ├── bot_ai.gd              ← class_name BotAI; seek-nearest-enemy-and-shoot brain, tuned per-bot by BotPersonality
    │   ├── bot_personality.gd     ← class_name BotPersonality; 5 independent 0-1 traits (aggression/caution/accuracy/reaction/awareness) rolled once per bot-slot
    │   └── bot_names.gd           ← class_name BotNames; procedural callsign pool
    ├── world/
    │   ├── world.tscn             ← game world; world.gd loads the active map, spawns flagships, brokers bullets/hazard colliders
    │   ├── world.gd                ← picks/instances a map scene from world/maps/ (MAPS array), decides offered races, spawns flagship formations, hosts MatchManager's ship-hold/creep-in hooks
    │   ├── world_tileset.tres      ← shared TileSet resource (walls.png + asteroids.png atlas sources)
    │   ├── flagship.tscn/gd        ← capital-ship point-defense formation (class_name Flagship), see CLAUDE.md item 24; also plays the PRE_MATCH creep-in tween
    │   ├── starfield.gdshader      ← procedural starfield background
    │   ├── game_modes/
    │   │   ├── game_mode.gd            ← class_name GameMode; base ruleset (win condition, score label, match duration) MatchManager drives every mode through
    │   │   └── team_deathmatch_mode.gd ← class_name TeamDeathmatchMode; most-kills-when-clock-runs-out, the only implemented mode today (see overview/map1.md)
    │   └── maps/
    │       └── map_01.tscn         ← hand-painted map: Walls + Asteroids TileMapLayers (asteroids deal impact damage, not just bounce), team spawn Marker2Ds
    └── assets/
        ├── icon.svg
        ├── default_bus_layout.tres  ← Master/Music/SFX audio buses
        └── images/
            ├── background/skybox/     ← space background PNGs
            ├── effects/
            │   ├── explosion/           ← ship-death explosion animation frames
            │   └── bullets/             ← per-race/role bullet sprites, incl. flagship turret fire
            ├── banners/                 ← faction banner art
            ├── ships/isn/, ships/orc/   ← live faction ship art (idle + _flame sprites), each with a source/ concept-sheet copy
            └── tiles/                   ← walls.png, asteroids.png, used by world_tileset.tres

assets/images/ships/apex/ and the older terran/mech/vorg folders are left on disk but unreferenced by any code — not deleted, see CLAUDE.md items 17/19.

Scene Graph

menu/main_menu.tscn — entry point

MainMenu (Control)   ← main_menu.gd builds all UI in _ready()

menu/server_select.tscn — server list

ServerSelect (Control)   ← server_select.gd builds all UI in _ready(), fetches GET /servers

world/world.tscn — game world

World (Node2D)                        ← world.gd instances a map into MapContainer, spawns flagships, brokers bullet/hazard colliders
├── MapContainer (Node2D)              ← holds the instanced map (world/maps/map_01.tscn), background included
├── Players (Node2D)                   ← per-peer ship.tscn instances live here
├── MultiplayerSpawner                 ← spawns/despawns ships into Players
├── Bullets (Node2D)                   ← server-spawned bullet.tscn instances
├── BulletSpawner (MultiplayerSpawner)
├── PauseMenu (CanvasLayer, layer=10)     ← menu/pause_menu.tscn
├── TeamSelect (CanvasLayer, layer=20)    ← menu/team_select.tscn; also reopened live from the pause menu's SELECT TEAM
├── SettingsPanel (CanvasLayer, layer=25) ← menu/settings_panel.tscn; opened by pause menu's SETTINGS
├── ChatBox (CanvasLayer, layer=5)        ← chat/chat_box.tscn
├── PlayerList (CanvasLayer)              ← hud/player_list.tscn
├── KillFeed (CanvasLayer, layer=5)       ← hud/kill_feed.tscn
├── MiniMap                               ← hud/mini_map.tscn
├── PingDisplay                           ← hud/ping_display.tscn
├── HealthBar                             ← hud/energy_bar.tscn instance, reused for health
├── EnergyBar                             ← hud/energy_bar.tscn instance
├── MatchTimer (CanvasLayer, layer=4)     ← hud/match_timer.tscn
├── Scoreboard (CanvasLayer, layer=6)     ← hud/scoreboard.tscn; visible only while Tab is held
└── MatchBanner (CanvasLayer, layer=30)   ← hud/match_banner.tscn; visible only during MatchManager's POST_MATCH phase

world/maps/map_01.tscn — hand-painted map

Map01 (Node2D)
├── Background (TextureRect)     ← per-map space background; procedural starfield.gdshader, not a tiled skybox
├── Walls (TileMapLayer)         ← painted by hand in the Godot Tile Editor, uses world_tileset.tres; bounces ships (environment_wall group)
├── Asteroids (TileMapLayer)     ← painted by hand, same shared tileset; bounces AND deals impact damage (environment_hazard group, GameConfig.ship_wall_damage)
└── SpawnPoints (Node2D)
    ├── TeamASpawn1 (Marker2D)   ← group "team_a_spawn"; also where world.gd spawns Team A's flagship formation
    └── TeamBSpawn1 (Marker2D)   ← group "team_b_spawn"; also where world.gd spawns Team B's flagship formation

ships/ship.tscn — player ship

Player (CharacterBody2D)   ← ship_movement.gd
├── CollisionShape2D        ← CircleShape2D
├── Sprite2D                ← texture set at runtime from PlayerRegistry's per-peer ship_path
└── VisibleOnScreenNotifier2D

Input Map

Action Key Controller
move_up W
move_left A
move_right D
move_down S
shoot Space
toggle_pause Esc Start / Options button (JoyButton 6)
chat_all T
chat_team Y
scoreboard Tab

Game Flow

main_menu.tscn
  ├── QUICK PLAY → matchmaking queue (casual) → world.tscn
  │     ├── TeamSelect overlay: pick race (both offered every match)
  │     ├── TeamSelect overlay: pick ship
  │     └── Player spawns → game live
  ├── SERVER SELECT → server_select.tscn → pick a server → world.tscn (same TeamSelect flow)
  ├── OPTIONS → settings_panel.tscn (audio, frame-rate cap, vsync, window mode, colorblind mode, key rebinding) — same panel the in-game pause menu's SETTINGS opens
  ├── PROFILE → callsign-edit overlay (also auto-opens from QUICK PLAY if no callsign is set)
  └── QUIT

Ranked matchmaking is removed from the menu entirely (not just disabled) until it's real — see CLAUDE.md Current Tasks.

Display

Launches borderless fullscreen at the real screen resolution (window/size/mode=3 in project.godot) with stretch mode disabled — 1 game pixel = 1 screen pixel everywhere, no UI/world scaling. Chosen over Godot's default canvas_items+expand stretch (which scales the whole 2D canvas to fill the window) because the game's design canvas is deliberately sized to the Steam Deck's native 1280×800; scaling that up to fill a PC monitor made everything look zoomed in. With stretch disabled, PC monitors just reveal more of the world/HUD at native size instead — the actual map (world/maps/map_01.tscn) is already a fixed-size 14016×6000 arena, not viewport-sized, so there's more world to reveal.

This means every menu Control has to position itself relative to the real window size, not a fixed 1280×800 design canvas — anchors (0.0=edge, 0.5=center, 1.0=opposite edge) plus fixed pixel offsets from that anchor, not raw absolute pixel coordinates. main_menu.gd and team_select.gd both have a _place()/direct-property-assignment helper for this — always set anchor properties directly (node.anchor_left = ...) rather than through sequential set_anchor_and_offset() calls: that method's default push_opposite_anchor=true drags the opposite side's anchor/offset along whenever two sequential calls momentarily disagree (e.g. left set to a 0.5 anchor while right is still its 0.0 default), corrupting layout — hit this exact bug converting the play-mode buttons to be centered.

Key Autoload — GameConfig

Per-peer identity (player_race, player_ship_path) moved out of GameConfig early on, per the networking plan's Phase 2 — that's PlayerRegistry's job now (see below). GameConfig today holds match-wide tuning constants plus persisted user settings. Grouped by area rather than listed exhaustively (~70 properties) — see autoload/game_config.gd for the full list:

Area Examples Notes
Persisted settings max_fps, vsync_mode, window_mode, master_volume/music_volume/sfx_volume, colorblind_mode, keybinds Loaded/applied in _ready(), written to user://settings.cfg by save_settings(); each has a set_*() that applies live
Ship movement ship_thrust, ship_max_speed, ship_rotation_speed, ship_rotation_ramp_delay Tuning constants
Combat ship_fire_rate, bullet_speed, ship_bullet_damage_by_role, ship_bullet_count_by_role, bullet_max_range Per-role dictionaries keyed "Fighter"/"Gunner"/"Tank"
Energy ship_max_energy_by_role, ship_bullet_energy_cost_by_role, ship_energy_regen_rate See CLAUDE.md item 23
Health/respawn ship_max_health, ship_respawn_delay, ship_invincibility_time, ship_wall_damage ship_wall_damage applies on both wall bounces and asteroid hazard hits
Collision/visuals ship_bounce_restitution, ship_collision_damage_scale, ship_scale_factor, ship_hitbox_scale, explosion_scale, damage_number_*
Colors team_color, enemy_color, colorblind_mode (swaps enemy_color between ENEMY_COLOR_DEFAULT/ENEMY_COLOR_COLORBLIND) Read by nameplates, hud/player_list.gd, hud/mini_map.gd
Camera/world camera_zoom, world_bounds, spawn_area_inner_radius/outer_radius
Flagships flagship_count_per_spawn, flagship_defense_radius, flagship_fire_rate, flagship_missile_*, flagship_bullet_*, flagship_aim_jitter_px, flagship_creep_in_distance See CLAUDE.md item 24; flagship_creep_in_distance is the PRE_MATCH cosmetic tween's start offset
Match structure default_game_mode_id, match_pre_match_duration, match_duration, match_post_match_duration Read by MatchManager's phase state machine — see item 26
Input-focus gates chat_focused, team_select_focused, settings_focused Each blocks ship movement/fire input while its overlay owns keyboard focus
Bot tuning bot_min_team_size, bot_engage_range, bot_stop_distance_min/max, bot_retreat_health_frac_min/max, bot_aim_tolerance_best_deg/worst_deg, bot_aim_jitter_max_px, bot_reaction_update_best_sec/worst_sec, bot_awareness_range_min/max Min/max pairs are interpolated per-bot by that bot's rolled BotPersonality (0=worst trait, 1=best)

Key Autoload — NetworkManager

Owns the ENetMultiplayerPeer (autoload/network_manager.gd). host_server(port) / join_server(ip, port). A hosted server self-registers with matchmaking-api on boot and re-heartbeats every 8s via MatchmakingClient.register_server() (reusing one persistent HTTPRequest node — a fresh one per call caused a periodic stutter, see CLAUDE.md item 20/21). Also runs a UDPServer on game-port+10000 (_start_query_responder) that answers a raw "SPACEWAR_PING" datagram with live player count, used by menu/server_select.gd's pre-connect probe (post-connect ping uses ENet's own RTT stat instead, via hud/ping_display.gd).

Key Autoload — PlayerRegistry

Per-peer loadout registry (autoload/player_registry.gd), Dictionary[peer_id, info] where info holds name/race/ship_path/ship_scale/ship_speed_factor/role/ ship_flame_path/ship_sound_path. submit_local_loadout() (real players) and register_bot() (bots, negative peer_ids) both funnel through the same server-relayed storage, so every system that reads a loadout (spawning, HUD, bots, bullets) treats players and bots identically. loadout_updated/race_changed/player_removed signals drive BotManager, hud/player_list.gd, and TeamSelect's live headcounts.

Key Autoload — MatchmakingClient

HTTP wrapper around matchmaking-api (autoload/matchmaking_client.gd) — queueing, list_servers() (backs menu/server_select.gd's GET /servers), and register_server()/heartbeat. Don't call the internal _request() directly from outside this file; use the public wrapper methods.

Key Autoload — BotManager

Server-authoritative bot fill for casual (bots/bot_manager.gd). Keeps each of the match's 2 offered factions at GameConfig.bot_min_team_size total humans+bots, reacting to PlayerRegistry.loadout_updated/race_changed/player_removed and World.decide_offered_races(). Also rolls a BotPersonality per bot-slot. See overview/bots.md.

Key Autoload — ChatManager

Server-relayed chat (autoload/chat_manager.gd). send_chat(text, team_only) sends; message_received(sender_name, sender_race, team_only, text) signal delivers incoming messages to ChatBox. "Team" messages are filtered server-side to peers sharing the sender's race (race doubles as team — see PlayerRegistry).

Key Autoload — KillFeedManager

Server-authoritative kill-feed broadcast (autoload/kill_feed_manager.gd). One RPC per death, called from ship_movement.gd's _die() — no client → server leg, unlike chat. kill_reported signal feeds hud/kill_feed.gd's last-4-lines display, and report_kill() also calls MatchStats.record_kill() — the one hook point both systems share.

Key Autoload — MatchStats

Per-match (resets every MatchManager loop) kills/deaths by peer_id, including bots (autoload/match_stats.gd). get_team_kills(race_id) sums a whole team via PlayerRegistry. stats_updated/stats_reset signals drive hud/scoreboard.gd.

Key Autoload — MatchManager

Server-authoritative match-phase state machine (autoload/match_manager.gd): PRE_MATCH (30s countdown, ships held via ship_movement.gd's server_hold_for_match_start(), flagships cosmetically creep into formation) → IN_PROGRESS (the active GameMode's clock runs, ships released) → POST_MATCH (winner banner, match result reported to matchmaking-api) → loops back into a fresh PRE_MATCH indefinitely — no menu kick, matching this project's always-on server-pool model (see item 16). Follows the same "decide once on the server, RPC-broadcast the value, late joiners pull it" pattern world.gd's decide_offered_races() established. Owns the active GameMode (world/game_modes/) instance, built identically on every peer off the shared GameConfig.default_game_mode_id constant — adding a new mode is a new GameMode subclass plus one factory branch, no changes to this state machine.

Key Autoload — MusicManager

Menu background-music player (autoload/music_manager.gd), routed through the Music audio bus so GameConfig.music_volume controls it live.