Lands the non-networked Phase 0 tasks from multiplayer-todo.md (ship/camera/ arena refactors, sim constants, background FPS handling) plus a first pass at exposing graphics/performance settings (presets, resolution scaling, vsync, FPS cap, perf overlay) and a GPU profiling harness for the real-hardware follow-up in task 0.15b.
12 KiB
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 viaBLENDER_HOST/BLENDER_PORTin.mcp.json). - If
install-addonrewrote 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_TELEMETRYin the server'senvblock 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 rungodot --path Gamefrom 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-mcpsubmodule is a separate Node/TypeScript project with its ownnpm 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) orscenes/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 readsInput. Each physics tick,_integrate_forcespulls oneShipAction(scripts/ship_action.gd: thrustVector3, rotationVector3, turbobool, each axis -1..1) from itsShipControllerchild (scripts/ship_controller.gd, base returns a zero action).PlayerShipControllerreads input actions; a futureAIShipController(RL policy) or network-replication controller implements the sameget_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 enclosingBoundary(instance ofobjects/arena_boundary.tscn: floor/walls/ceiling colliders), twoGoalinstances (team 0 and 1),BallSpawnandSpawnsTeam0/1Marker3Ds — queried viaget_ball_spawn()/get_ship_spawns(team)/get_goals(). All arenas are a standard size: they instance the sharedarena_boundary.tscn, andscripts/arena_boundary.gd(ArenaBoundary) holds the canonical play-volume constants (INNER_HALF_X18,INNER_HALF_Z27,INNER_HEIGHT18,GOAL_LINE_Z=INNER_HALF_Z) that field-size logic must derive from instead of restating numbers. Game modes extendGameMode(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.gdandmatch_mode.gdoverride_start()and_on_goal_scored(conceding_team). - Goals are dumb sensors:
scripts/goal.gd(Area3D, group"goal",@export team) only emitsgoal_scored(team)when a body in group"ball"enters;GameModedebounces 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; seeFLIGHT_MANUAL.mdfor the player-facing flight model. Physics properties (mass, inertia, friction material) live inobjects/ship.tscn, not in_readyoverrides — 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) thatShipandBall(scripts/ball.gd) each apply in their own_integrate_forceswith independently-tuned strength/range, discovered via the"arena_boundary"group — enabling wall-rides and ceiling shots with no collision-shape changes. Because it runs insideShip's shared_integrate_forces, it reaches trained bots too; seeTRAINING.mdfor 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 atargetship — ships have no camera/HUD dependency, so headless RL runs work (godot --headless). - HUD / telemetry pattern:
Shipemits flight data via signals only when values change past thresholds (_last_*fields,*_THRESHOLDconstants).HUDController(scripts/HUDController.gdonscenes/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 viaGameMode.spawn_camera_rig(mirroring the camera rig'starget, 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 hardcodedget_nodepaths or per-frame polling. - Input actions are defined in
Game/project.godotunder[input](move_forward,turn_left,turbo,reset_ball, etc.) and read only byPlayerShipController(plus mode-level_unhandled_inputforreset_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, extendsGameMode) is the headless self-play environment: two ships driven byRLShipControllers, withShipAIController(extends the vendored plugin'sAIController3D) as the only class touching godot_rl types. The plugin is vendored (not a submodule) atGame/addons/godot_rl_agents— see itsVENDORED.md; its C#/ONNX files are unused.scripts/ship_observations.gdis 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(aShipController) runs the exported policy JSON viascripts/policy_network.gd(pure-GDScript MLP) — no .NET/ONNX/Python at runtime. Models live inGame/bots/; Match mode'sbot_model_path/bot_reaction_ticks/bot_action_noiseexports 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, appendstraining/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._decideconsumes exported policies in sorted order; change the action space only deliberately and everywhere together.