# 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 + " 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.