HUDController found its ship via get_first_node_in_group("ship"), a group
that has 2+ members once a match has an AI opponent — it only worked
because the player ship happened to spawn first. spawn_camera_rig now
wires the HUD's ship the same way it already wires the camera rig's
target.
10 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 add https://github.com/ahujasid/blender-mcp.git mcp/blender-mcp
cd mcp/blender-mcp
uv sync
One-time manual step (GUI, can't be scripted): install the Blender addon — Blender → Edit → Preferences → Add-ons → Install → select mcp/blender-mcp/addon.py → enable "Interface: Blender MCP". Blender must be running with the addon's socket server started (default localhost:9876) for the MCP tools to connect; override with the BLENDER_HOST / BLENDER_PORT env vars in .mcp.json if needed.
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.