Upstream's update added telemetry that is on by default. Sets the three opt-out env vars on the blender server in .mcp.json, which also suppresses the consent prompt it would otherwise raise through the MCP client. Documents the addon-side 'Allow Telemetry' preference as a separate switch: it defaults to on, covers prompts, code snippets, screenshots and trajectory data, and lives inside Blender where these env vars cannot reach it.
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 x ±12, z ±18, height 12, goal lines z ±17) 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.