Files
CosmicClash/CLAUDE.md
T
Josh Creek 4533da34e0 feat(multiplayer): Phase 1 transport, connection, and lobby
Lands tasks 1.0-1.8 of multiplayer-todo.md: the pure-function test runner,
net_codec (wire format quantizers/pack-unpack), NetworkManager (ENet
transport, manual polling, min-RTT clock sync), MatchNet (handshake,
protocol/tick-rate gating, roster with team+ready state), lobby.tscn (team
columns, switch team, ready toggle), server_boot.tscn (headless dedicated
server with structured logging and an overrun watchdog), and main_menu.gd's
Host/Join-by-IP UI (connecting overlay, cancel, bounded failure path).

Followed by an adversarial review (Opus subagent) that found and fixed two
real bugs - an unvalidated player_name broadcast that let one client's
oversized name head-of-line-block the reliable channel for everyone, and a
server-side roster leak across a host/re-host cycle - plus three gaps in
the test suite itself where a claim of "verified" wasn't actually backed
by what the test checked. All five two-process smoke tests plus the
pure-function suite are green with the strengthened assertions in place.
2026-08-20 08:18:59 +01:00

14 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Important rule: never create co-authored commits. Never mention Claude in commits.

Project overview

Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.7, using space ships instead of cars. The project is GDScript/Godot only right now — the "C# backend" mentioned in README.md is planned but not yet started. There is no server-side code; the MVP is local-only play against bots.

Because the gameplay concept (vehicle soccer) can't be copyrighted but specific expression can, all code/art/assets must be original — this is why the project uses Godot instead of Unreal/Unity and ships instead of cars. Keep this in mind when writing code or pulling in assets: don't port or closely mirror Rocket League's actual implementation.

Godot MCP server

This repo vendors godot-mcp as a git submodule at mcp/godot-mcp and registers it in .mcp.json. Prefer the godot-mcp tools over manual file edits or shell commands when the task involves inspecting or modifying the Godot project — reading/editing scenes, nodes, scripts, running the project, or interacting with a live Godot editor/runtime instance. It understands Godot's scene tree and .tscn/.gd structures directly, which is more reliable than hand-parsing them.

Setup after cloning (submodules aren't checked out by default):

git submodule update --init --recursive
cd mcp/godot-mcp
npm install
npm run build

GODOT_PATH (env var in .mcp.json) is left blank to auto-detect the Godot executable; set it explicitly if auto-detection fails on your machine.

Blender MCP server

This repo vendors blender-mcp as a git submodule at mcp/blender-mcp and registers it in .mcp.json. Prefer the blender-mcp tools over manual scripting when the task involves creating or editing 3D models, materials, or scenes in Blender (e.g. ship/arena assets) — it drives a live Blender instance directly rather than hand-writing .blend/Python scene-manipulation code.

Setup after cloning (same submodule caveat as godot-mcp above): git submodule update --init --recursive.

Preflight — run this before the first blender-mcp tool call in a session (both commands are idempotent and quick when already current; don't repeat them for later calls in the same session):

cd mcp/blender-mcp
uv sync                           # the submodule pointer moves often and deps drift with it
uv run blender-mcp install-addon  # copies the bundled addon into Blender's user addons dir

install-addon replaced the old manual GUI install (Preferences → Add-ons → Install → addon.py). It discovers Blender's addons directory itself (uv run blender-mcp addon-paths lists candidates; --addons-dir or BLENDERMCP_ADDONS_DIR overrides), backs up any existing copy, and is version-aware — it compares ADDON_PROTOCOL_VERSION in the installed file against the bundled one and rewrites only when the installed copy is missing or older, so a no-op run is cheap. The source of truth is src/blender_mcp/bundled/addon.py; the repo-root addon.py is an identical copy kept for the legacy manual path.

What still cannot be scripted, and is the user's job — ask them rather than retrying a failing tool call:

  • Blender must be running with "Interface: Blender MCP" enabled and its socket server started (default localhost:9876; override via BLENDER_HOST / BLENDER_PORT in .mcp.json).
  • If install-addon rewrote the addon while Blender was open, the new code isn't loaded until Blender restarts or the addon is disabled/re-enabled. Treat "install-addon reported an update" as a signal to tell the user to restart Blender before continuing.

Upstream ships telemetry, and there are two independent switches — turning off one does not affect the other:

  • Server side: disabled here via DISABLE_TELEMETRY / BLENDER_MCP_DISABLE_TELEMETRY / MCP_DISABLE_TELEMETRY in the server's env block in .mcp.json. This also suppresses the consent prompt that would otherwise arrive through the MCP client on first use. Keep these set when editing .mcp.json.
  • Addon side, inside Blender: an "Allow Telemetry" checkbox in the addon's preferences that defaults to on and covers prompts, code snippets, screenshots and trajectory data, plus manual-edit capture handlers. The env vars above cannot reach it — it is a Blender preference. Untick it at Preferences → Add-ons → Blender MCP, or clear it with set_telemetry_consent(false) via the addon's command channel.

Commands

There is no build step or linter for the GDScript project itself — Godot projects run directly from source.

  • Open the project: open Game/ as a project in the Godot 4.7 editor, or run godot --path Game from the repo root.
  • Run the game: press Play in the editor, or godot --path Game res://scenes/main_menu.tscn.
  • Headless smoke test (RL/CI precondition — the game must run without rendering): godot --headless --path Game res://scenes/free_play.tscn.
  • Unit tests (pure-function assertions, see multiplayer-todo.md task 1.0): godot --headless --path Game res://tests/test_runner.tscn. Exits 0/1. Add a test by dropping a *.gd file under Game/tests/cases/ that extends res://tests/test_case.gd (path-based extends, not the bare class_name — see that file for why) with any number of test_*() methods; the runner discovers it, no registration needed.
  • Networking smoke tests (real two-process ENet connect/disconnect, see multiplayer-todo.md §7 Phase 1 tasks): each starts a host then a client, each in its own godot --headless process, printing SMOKE PASS/FAIL: ... and exiting 0/1. Not part of test_runner.tscn — a live ENet handshake needs two real processes. res://tests/net_smoke.tscn (task 1.2 — --role=host|client, now also confirms the host observes client_disconnected, not just that each side exits cleanly on its own), res://tests/match_net_smoke.tscn (task 1.4 — --role=host|client|client-badversion|client-longname|host_recycle; client-longname sends an oversized player name and expects rejection, host_recycle hosts, lets a client join, leaves, re-hosts, and confirms the roster is actually empty — run a plain client role against it), res://tests/clock_smoke.tscn (task 1.8 — --role=host|client, clock convergence cross-checked against independent OS-wall-clock ground truth, not just self-consistency), res://tests/lobby_smoke.tscn (task 1.5 — --role=host|client; both roles load lobby.tscn for real via change_scene_to_file now, exercising the server's read-only view as well as the client's interactive one). See network_manager.gd's header comment and multiplayer-todo.md §9 gotchas 2530 for the non-obvious Godot/ENet failure modes these caught (OfflineMultiplayerPeer sentinel, premature peer teardown, change_scene_to_file off the real current_scene, unbounded connection_failed, the is_client-before-actually-connected race, load() not returning null on a broken script).
  • main_menu.tscn's Host/Join flow (task 1.7) is verified the same way but needs a temporary autoload since it's the real main scene, not a wrapper: add MainMenuTestHooks="*res://tests/main_menu_test_hooks.gd" to project.godot [autoload], run godot --headless --path Game res://scenes/main_menu.tscn -- --role=<host|join_ok|join_refused|join_cancel> (host first, sleep ~1s, then the join role), then remove the autoload line again — it must never ship registered.
  • The mcp/godot-mcp submodule is a separate Node/TypeScript project with its own npm install / npm run build (see above) — it is tooling, not part of the game itself.

Architecture

The structure was deliberately chosen so an RL-trained AI opponent and, later, multiplayer bolt on without rework (see TODO.md for the deferred work). The three load-bearing seams are the controller abstraction, the arena/game-mode split, and code-driven spawning.

  • Scene flow: scenes/main_menu.tscn (main_menu.gd, one handler per mode) → scenes/free_play.tscn (practice: no timer, R resets ball) or scenes/match.tscn (150s timer, per-team score, kickoff resets). Esc returns to the menu from either mode.
  • Controller seam (do not bypass): Ship (scripts/ship.gd, RigidBody3D) never reads Input. Each physics tick, _integrate_forces pulls one ShipAction (scripts/ship_action.gd: thrust Vector3, rotation Vector3, turbo bool, each axis -1..1) from its ShipController child (scripts/ship_controller.gd, base returns a zero action). PlayerShipController reads input actions; a future AIShipController (RL policy) or network-replication controller implements the same get_action() interface. A ship with no controller is inert but simulated. The ShipAction shape is the future RL action space — change it deliberately.
  • Arena vs game mode: scenes/arena_01.tscn (scripts/arena.gd, group "arena") is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing Boundary (instance of objects/arena_boundary.tscn: floor/walls/ceiling colliders), two Goal instances (team 0 and 1), BallSpawn and SpawnsTeam0/1 Marker3Ds — queried via get_ball_spawn()/get_ship_spawns(team)/get_goals(). All arenas are a standard size: they instance the shared arena_boundary.tscn, and scripts/arena_boundary.gd (ArenaBoundary) holds the canonical play-volume constants (INNER_HALF_X 18, INNER_HALF_Z 27, INNER_HEIGHT 18, GOAL_LINE_Z = INNER_HALF_Z) that field-size logic must derive from instead of restating numbers. Game modes extend GameMode (scripts/game_mode.gd, group "game"): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera in code (spawn_ship(team, index, controller) etc.) so ship counts and controller mixes stay flexible. free_play.gd and match_mode.gd override _start() and _on_goal_scored(conceding_team).
  • Goals are dumb sensors: scripts/goal.gd (Area3D, group "goal", @export team) only emits goal_scored(team) when a body in group "ball" enters; GameMode debounces it (_handle_goal_scored) and modes decide consequences. Never put scoring/reset logic in the goal.
  • Ship physics: all movement is force/torque-based (_integrate_forces), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics formulas are commented inline; see FLIGHT_MANUAL.md for the player-facing flight model. Physics properties (mass, inertia, friction material) live in objects/ship.tscn, not in _ready overrides — keep the scene truthful; RL tuning depends on it.
  • Surface pull (wall/ceiling grav-plating): ArenaBoundary.get_surface_pull() is a wall+ceiling-only proximity force field (the floor stays plain default gravity) that Ship and Ball (scripts/ball.gd) each apply in their own _integrate_forces with independently-tuned strength/range, discovered via the "arena_boundary" group — enabling wall-rides and ceiling shots with no collision-shape changes. Because it runs inside Ship's shared _integrate_forces, it reaches trained bots too; see TRAINING.md for the retrain this warrants.
  • Camera (scenes/ship_camera_rig.tscn, scripts/ship_camera.gd, group "ship_camera") is spawned by the game mode and given a target ship — ships have no camera/HUD dependency, so headless RL runs work (godot --headless).
  • HUD / telemetry pattern: Ship emits flight data via signals only when values change past thresholds (_last_* fields, *_THRESHOLD constants). HUDController (scripts/HUDController.gd on scenes/HUD.tscn, instanced by each mode's scene) discovers the camera rig and game mode via groups ("ship_camera", "game") but receives its target ship directly from the game mode via GameMode.spawn_camera_rig (mirroring the camera rig's target, not group lookup — the "ship" group can have 2+ members), connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments or cross-node communication, not hardcoded get_node paths or per-frame polling.
  • Input actions are defined in Game/project.godot under [input] (move_forward, turn_left, turbo, reset_ball, etc.) and read only by PlayerShipController (plus mode-level _unhandled_input for reset_ball/ui_cancel) — add new controls there rather than hardcoding key checks.
  • Physics engine is Jolt (Game/project.godot, [physics] 3d/physics_engine="Jolt Physics").

Reinforcement learning / AI bots

See TRAINING.md for the full workflow (training, exporting, evaluating, difficulty tiers). Architecture summary:

  • scenes/training.tscn (scripts/training_mode.gd, extends GameMode) is the headless self-play environment: two ships driven by RLShipControllers, with ShipAIController (extends the vendored plugin's AIController3D) as the only class touching godot_rl types. The plugin is vendored (not a submodule) at Game/addons/godot_rl_agents — see its VENDORED.md; its C#/ONNX files are unused.
  • scripts/ship_observations.gd is the shared observation builder used by both training and in-game inference — never fork or diverge these two paths. Team 1's observations are mirrored (180° about Y) so one policy plays both sides.
  • In-game bots: scripts/ai_ship_controller.gd (a ShipController) runs the exported policy JSON via scripts/policy_network.gd (pure-GDScript MLP) — no .NET/ONNX/Python at runtime. Models live in Game/bots/; Match mode's bot_model_path/bot_reaction_ticks/bot_action_noise exports configure the opponent.
  • Python side lives in training/ (venv, not committed): train.py (SB3 PPO, launches parallel headless Godot instances from source), export_policy.py (checkpoint → JSON with parity check), evaluate.py (head-to-head eval, appends training/eval_history.json).
  • The flattened action space is Box(7) in gymnasium's sorted-key order: rotation xyz, thrust xyz, turbo (>0 = on). The fields are ShipAction's, but gymnasium alphabetizes Dict spaces, so the flat order is NOT ShipAction's thrust-first declaration order — AIShipController._decide consumes exported policies in sorted order; change the action space only deliberately and everywhere together.