Files
CosmicClash/CLAUDE.md
T
Josh Creek 884b7799a0 fix(hud): have game mode hand HUD its target ship instead of group lookup
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.
2026-08-04 19:54:55 +01:00

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 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 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.