Lands tasks 1.0-1.8 of multiplayer-todo.md: the pure-function test runner, net_codec (wire format quantizers/pack-unpack), NetworkManager (ENet transport, manual polling, min-RTT clock sync), MatchNet (handshake, protocol/tick-rate gating, roster with team+ready state), lobby.tscn (team columns, switch team, ready toggle), server_boot.tscn (headless dedicated server with structured logging and an overrun watchdog), and main_menu.gd's Host/Join-by-IP UI (connecting overlay, cancel, bounded failure path). Followed by an adversarial review (Opus subagent) that found and fixed two real bugs - an unvalidated player_name broadcast that let one client's oversized name head-of-line-block the reliable channel for everyone, and a server-side roster leak across a host/re-host cycle - plus three gaps in the test suite itself where a claim of "verified" wasn't actually backed by what the test checked. All five two-process smoke tests plus the pure-function suite are green with the strengthened assertions in place.
14 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 or linter 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. - Unit tests (pure-function assertions, see
multiplayer-todo.mdtask 1.0):godot --headless --path Game res://tests/test_runner.tscn. Exits 0/1. Add a test by dropping a*.gdfile underGame/tests/cases/that extendsres://tests/test_case.gd(path-basedextends, not the bareclass_name— see that file for why) with any number oftest_*()methods; the runner discovers it, no registration needed. - Networking smoke tests (real two-process ENet connect/disconnect, see
multiplayer-todo.md§7 Phase 1 tasks): each starts a host then a client, each in its owngodot --headlessprocess, printingSMOKE PASS/FAIL: ...and exiting 0/1. Not part oftest_runner.tscn— a live ENet handshake needs two real processes.res://tests/net_smoke.tscn(task 1.2 —--role=host|client, now also confirms the host observesclient_disconnected, not just that each side exits cleanly on its own),res://tests/match_net_smoke.tscn(task 1.4 —--role=host|client|client-badversion|client-longname|host_recycle;client-longnamesends an oversized player name and expects rejection,host_recyclehosts, lets a client join, leaves, re-hosts, and confirms the roster is actually empty — run a plainclientrole against it),res://tests/clock_smoke.tscn(task 1.8 —--role=host|client, clock convergence cross-checked against independent OS-wall-clock ground truth, not just self-consistency),res://tests/lobby_smoke.tscn(task 1.5 —--role=host|client; both roles loadlobby.tscnfor real viachange_scene_to_filenow, exercising the server's read-only view as well as the client's interactive one). Seenetwork_manager.gd's header comment andmultiplayer-todo.md§9 gotchas 25–30 for the non-obvious Godot/ENet failure modes these caught (OfflineMultiplayerPeersentinel, premature peer teardown,change_scene_to_fileoff the realcurrent_scene, unboundedconnection_failed, theis_client-before-actually-connected race,load()not returning null on a broken script). main_menu.tscn's Host/Join flow (task 1.7) is verified the same way but needs a temporary autoload since it's the real main scene, not a wrapper: addMainMenuTestHooks="*res://tests/main_menu_test_hooks.gd"toproject.godot [autoload], rungodot --headless --path Game res://scenes/main_menu.tscn -- --role=<host|join_ok|join_refused|join_cancel>(host first, sleep ~1s, then the join role), then remove the autoload line again — it must never ship registered.- 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.