# 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](https://github.com/tugcantopaloglu/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): ```bash 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](https://github.com/ahujasid/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): ```bash 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, linter, or automated test suite 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`. - 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 x ±12, z ±18, height 12, goal lines z ±17) 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 `RLShipController`s, 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.