Adds the lesson this branch paid for repeatedly: the expensive failures were not broken behaviour but assertions that could not distinguish the two states they implicitly claimed to, each reporting its own ambiguity as a confident verdict about the system under test. Waiting on a Fleet field that does not exist, treating not-yet-started as exited, a p99 that conflated regression with scheduler noise, a validator reading a response shape Agones never sends, and a build guard that verified stale code. Five in one session, several costing multiple CI round trips. Two habits go with it, both of which beat reading code every time they were tried: make the script report what it saw before theorising about why, and verify the diagnostics actually fire -- two dumps were added here and neither ran, one suppressed by a reachability guard and one by an ERR trap that cannot fire inside functions without errtrace. Also fixes two stale claims and one gap. Audio is no longer "none at all"; a procedural AudioManager covers UI, countdown, impact, goal and engine cues, and only authored assets remain. Five docs/ contracts that server/security asserts against the manifests were unlisted. And the Go control plane -- a third of the codebase and the current focus -- had no structural description at all, so it now gets one: package layout, which binary is test-only, and the three things easiest to get wrong (integration tests hidden behind a build tag, start-time config, the versioned wire contract). TODO.md's entry now points at its ordered backlog rather than describing it as deferred non-multiplayer work. Every factual claim in the new section was checked against the tree.
36 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 game and the dedicated server are GDScript/Godot only — the "C# backend" an early README described was never started, and the dedicated server is an export of this same Godot project. There is one component outside the Godot project: a Go matchmaking control plane in server/ for casual/ranked queues, ranked ratings and Agones-based server allocation. It is a 1.0 launch blocker — see docs/MATCHMAKING.md for the design, multiplayer-next.md §0 and §7 for what remains (the allocation-to-connect pipeline is now wired end to end; what is left is external — a Steamworks App ID, custom GodotSteam builds, a registry to publish images to, and a live Agones cluster; TODO.md orders them), and docs/TECH_STACK.md for why the control plane is Go rather than C#, Rust or C++. README.md's "MVP is local-only against bots" section is historical: server-authoritative online multiplayer, a headless dedicated server, Docker/CI verification, and an optional Steam transport are all implemented (Phases 1–6). See multiplayer-next.md for what actually remains.
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.
Where the documentation lives
The prose docs carry far more design rationale than the code comments, and several are load-bearing:
multiplayer-next.md— the multiplayer task-tracking document: outstanding work (§0), a numbered "gotchas" list (§9), and the current task breakdown with checkboxes (§7), all in one file. Start at §0 for "what's left". Day-to-day work is Phase 7 (Steam) and Phase 8 (matchmaking), whose numbered task breakdown and acceptance criteria live in §7, because that is the format tasks are picked up from. Phases 0–6 are done and archival — their evidence lives in git history, not the current doc.MULTIPLAYER_SPEC.md— the architecture decisions, wire format, server-side input handling, prediction/reconciliation, latency/frame-rate budget and match lifecycle state machine, as sections 1–6. Code comments acrossGame/scripts/cite it constantly by section number (§2.4,§4.1); many still saymultiplayer-next.md §NforN1–6 from before this doc was split out — when a comment does, the content is now here, not there.multiplayer-next.md's own §7+ cites§Nthe same way and disambiguates by number (1–6 → this doc, 7+ → itself).TRAINING.md— the full RL workflow (training, curriculum generations, export, eval, difficulty tiers).SERVER.md— dedicated-server build, config, systemd deploy, sizing.STEAM.md— optional GodotSteam custom-build setup and the transport contract.FLIGHT_MANUAL.md— the player-facing flight model.docs/MATCHMAKING.md— casual/ranked queue design, and the locked constraints (Go/PostgreSQL/Redis/Agones) theserver/module implements. Partially implemented; a 1.0 launch blocker, and the reason a backend service outside the Godot project exists at all.docs/TECH_STACK.md— what the project is built with and why, including the Go-vs-C#/Rust/C++ rationale for the matchmaking control plane.TODO.md— deferred non-multiplayer work, and the ordered human-actionable backlog: which GitHub issue to do first, what each one unblocks, and which items are waiting on nobody. Start there when asking "what next". Audio is no longer absent — a proceduralAudioManagercovers UI, countdown, impact, goal and engine/turbo cues; what remains is authored assets.docs/THREAT-MODEL.md,docs/SUPPLY-CHAIN.md,docs/OBSERVABILITY.md,docs/MATCHMAKING-SLOs.md,docs/ADR-001-matchmaking-platform.md— the control plane's security, release, telemetry and SLO contracts.server/security/*.pyasserts several of them against the checked-in manifests, so changing a manifest often means changing one of these.docs/REVIEW-2026-09-feat-multiplayer.md— a point-in-time adversarial review of this branch. Every finding in it is fixed; it is kept for the reasoning, not as a status report, and its header says so.
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.
Running the game
- 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. - Import resources first on a fresh checkout or in a container:
godot --headless --path Game --import. Do not add--quit— it ends the editor after one iteration and can leave.godot/importedhalf-generated (see the Dockerfile comment).
Unit tests
godot --headless --path Game res://tests/test_runner.tscn — pure-function assertions, exits 0/1.
Add a test by dropping a *.gd file under Game/tests/cases/ that extends res://tests/test_case.gd (path-based extends, not the bare class_name — see that file for why) with any number of test_*() methods; the runner discovers it, no registration needed. The same path-vs-class_name caveat is why net_codec.gd, sim_constants.gd etc. are reached by preload() elsewhere in the codebase.
The runner has no filter flag — it always runs everything (it's fast). To isolate one case, temporarily move the others out of tests/cases/.
Two runner behaviours exist because of past silent-pass bugs, and new tests must respect them: a case file that fails to parse is a failure (can_instantiate() is the guard — load() does not return null on a broken script), and a test that completes having made zero assertions is itself a failure, because GDScript has no exceptions and a crash before the first assert_* would otherwise read as a pass.
Networking smoke tests (multi-process)
These need two or three real godot --headless processes for a live ENet handshake, so they are not part of test_runner.tscn. Each prints SMOKE PASS/FAIL: ... and exits 0/1. Run them all through the wrapper:
make verify-enet-integration # host+client pairs for every case, then the 3-process match
VERIFY_ENET_CASES=net,clock make verify-enet-integration # subset, for local debugging
GODOT_BIN=/path/to/godot make verify-enet-integration # non-default Godot binary
scripts/verify_enet_integration.sh starts each role, waits, and fails on any SCRIPT ERROR/ERROR:/SMOKE FAIL in the logs — a clean exit code alone is not the bar. It prints its temp log directory and dumps the logs on failure. The individual scenes, if you need to drive one by hand with -- --role=<role>:
| Scene | Roles | What it proves |
|---|---|---|
tests/net_smoke.tscn |
host, client |
Raw connect/disconnect; the host observes client_disconnected, not just clean self-exit. |
tests/match_net_smoke.tscn |
host, client, client-badversion, client-longname, host_recycle |
Handshake gating (protocol version, oversized name rejection) and that a host→leave→re-host cycle actually empties the roster (run a plain client against host_recycle). |
tests/clock_smoke.tscn |
host, client |
Clock convergence, cross-checked against independent OS-wall-clock ground truth rather than self-consistency. |
tests/lobby_smoke.tscn |
host, client |
Both roles load lobby.tscn for real via change_scene_to_file, exercising the server's read-only view as well as the client's interactive one. |
tests/networked_match_ci.tscn |
host, client-bot --test-bot (×2) |
A full server + two AI-driven clients playing a real match: snapshot throughput and cross-peer score agreement, via a deterministic server-forced goal. |
tests/networked_match_smoke.tscn |
see its header | Shorter attended variant of the above. |
tests/net_sim_smoke.tscn |
see its header | The --net-sim-* latency/loss decorator actually changes observed behaviour. |
See network_manager.gd's header comment and multiplayer-next.md §9 gotchas 25–30 for the non-obvious Godot/ENet failure modes these caught (OfflineMultiplayerPeer sentinel, premature peer teardown, change_scene_to_file off the real current_scene, unbounded connection_failed, the is_client-before-actually-connected race, load() not returning null on a broken script).
main_menu.tscn's Host/Join flow is verified the same way but needs a temporary autoload since it's the real main scene, not a wrapper: add MainMenuTestHooks="*res://tests/main_menu_test_hooks.gd" to project.godot [autoload], run godot --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.
tests/server_physics_parity.gd is a standalone SceneTree script (no .tscn, no wired runner) that traces the real Ship scene through fixed inputs and dumps every physics step's pose/velocity plus the observation vector. It exists to be diffed against the same file run from a git archive HEAD tree, proving a client-side change didn't perturb shared physics.
Dedicated server (Docker)
make verify-phase6 # the whole exported-server gate; also the entire Phase 6 CI workflow
This builds the stripped Linux Dedicated Server export, runs it in one container, joins two independent headless clients from two others, forces a server-owned goal in each of two matches, and asserts both clients saw both scores and that the arena actually rotated between matches. It needs several GB of free Docker space (the pinned barichello/godot-ci:4.7.1 image is ~2.4 GB) and always tears down its Compose containers, printing its log directory either way.
The Dockerfile's targets are worth knowing: project-imported (base, resources imported) → enet-test (source client, used by the ENet CI workflow) → exporter (rewrites run/main_scene and exports the server) → server (slim Ubuntu runtime) and smoke-client (test-only harness). Godot dedicated exports refuse command-line scene overrides, which is why the server scene is baked in by sed at export time rather than passed as an argument.
To run one by hand, and for every config flag, see SERVER.md. --smoke-force-goal-after=<seconds> is a verification-only switch and must never be used for a real match.
Steam builds (optional)
make verify-steam-templates requires custom GodotSteam executables pinned in steam-dependencies.lock.json and pointed at by COSMIC_CLASH_STEAM_CLIENT_GODOT / COSMIC_CLASH_STEAM_SERVER_GODOT. It deliberately refuses a stock Godot binary. Nothing else in the repo needs Steam — the default build and every Docker check are ENet-only. See STEAM.md.
CI
.github/workflows/ has eight jobs, all but one running a Make target:
| Workflow | Runs | Needs |
|---|---|---|
server-unit-tests.yml |
go build/vet/vet -tags integration/test/test -race in server/ |
nothing (no Docker) |
dedicated-server-smoke.yml |
make verify-phase6 |
Docker, several GB |
enet-integration.yml |
make verify-enet-integration inside the enet-test image |
Docker |
allocated-compose.yml |
make verify-allocated-compose |
Docker Compose |
agones-integration.yml |
make verify-kind-agones |
kind + Helm |
multiplayer-chaos.yml |
make verify-chaos-recovery |
Docker |
multiplayer-load.yml |
make verify-multiplayer-load (two -tags load Go tests) |
— |
supply-chain.yml |
make verify-supply-chain |
— |
The Godot unit suite runs via verify-phase6, which invokes test_runner.tscn as its first step — there is no separate Godot workflow. The Go unit suite has its own workflow because until it existed the only Go tests CI ran were multiplayer-load's two load tests, so ~24k lines of control plane gated nothing.
Note what is not in CI: make verify-multiplayer-local (the combined local gate, which also runs the Python manifest/contract suites) and the integration-tagged Go tests, which need a real PostgreSQL/Redis and live in scripts/run_*_integration.sh. Run those by hand before landing server changes.
When a gate fails, suspect the assertion first
The most expensive failures in this repo have not been broken behaviour. They have been assertions that cannot distinguish the two states they implicitly claim to, each reporting its own ambiguity as a confident verdict about the system under test. Five in one session, several costing multiple CI round trips:
| Assertion | What it actually conflated |
|---|---|
kubectl wait --for=jsonpath='{.status.ready}' on an Agones Fleet |
field does not exist vs. condition unmet — it could never pass |
compose ps --status running | grep -qx game-server |
not started yet vs. exited |
remote_residual_position_p99 < 0.3 |
real regression vs. host scheduling noise |
a validator reading status.gameServer |
Agones' real response vs. an invented one, with unit tests asserting the invention |
docker image inspect guarding a build |
image is current vs. image merely exists, so a rerun verified stale code |
Before theorising about the code, ask: can this check tell "broken" from "not ready yet", "absent" from "unset", or "regressed" from "slow"? If not, that is the bug, whatever else is also true.
Two habits follow from it, and both repeatedly beat reading code:
- Make the script say what it saw before diagnosing why. Most gates here are
curl -fsSand bare[[ ]]underset -e, which abort silently — several CI runs produced nothing butmake: *** Error 1. Report the failing line and command, print the value that failed its comparison, and dump the surrounding state before any cleanup trap destroys it. Every root cause found in that session came from doing this; essentially every confident guess made without it was wrong. - Verify the diagnostics fire. Two separate dumps were added and neither ran:
one behind a
kubectl cluster-infoguard that misjudged reachability, one because a baretrap ... ERRdoes not fire inside functions or subshells withoutset -E. A diagnostic that has never been seen working is not evidence.
And when a test and the code agree but reality disagrees, suspect they were written together. A validator and its fixtures both encoded a response shape Agones never sends; nothing caught it because the gate had never run far enough to see a real one.
Other
- 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. Game/tools/holds editor-run utilities (bake_arena_boundary.gd,gpu_profile_harness.tscn,replay_dump.gdfor reading server replay logs);tools/blender/andtools/textures/hold the Python generators for the original ship/ball/nebula assets.
Architecture
The structure was deliberately chosen so an RL-trained AI opponent and, later, multiplayer bolt on without rework. The load-bearing seams are the controller abstraction, the arena/game-mode split, code-driven spawning, and (for networking) the pure-function codec/state modules that can be unit-tested without a live connection.
Core game
- Scene flow:
scenes/main_menu.tscn(main_menu.gd, one handler per mode) →free_play.tscn(practice: no timer, R resets ball),match.tscn(150s timer, per-team score, kickoff resets),spectate.tscn(bot vs bot exhibition),settings.tscn, or — for online —lobby.tscn→networked_match.tscn. Esc returns to the menu. Canonical paths live inscripts/scene_paths.gd; use those constants rather than string literals. - 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;AIShipControllerruns an RL policy;RLShipControlleris driven by the training bridge;LocalNetShipControllerwraps another controller to record inputs into the network timeline. A ship with no controller is inert but simulated. The ShipAction shape is the RL action space and is what the wire format quantises — change it deliberately and everywhere at once. - Arena vs game mode: an arena (
scripts/arena.gd, group"arena") is a stateless stadium — a setting, an enclosingBoundary(instance ofobjects/arena_boundary.tscn), twoGoalinstances,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. - Arena registry:
scripts/arena_registry.gdis the single source of truth for the arena list — three settings × floor/elevated goal variants."random": truegates which arenas Match/Spectate/the dedicated server may pick; elevated-goal variants are Free-Play-only until a checkpoint trained ontraining_elevated.tscnis promoted, because the current bots cannot score on an elevated goal.path_for_match(match_index, mode)is deliberately pure arithmetic so "the server cycles arenas" is unit-testable. The Go control plane keeps its own copy of the ranked-eligible subset (server/domain/ranked.go'srankedArenas), because ranked arena selection is a server-authoritative decision made before any Godot process exists. That copy is not free to drift:server/domain/arena_registry_sync_test.goparses this file and fails if the two disagree in either direction, or if a ranked path has no scene behind it. Editing the arena list therefore means updatingranked.gotoo — the test says so when it fails.arena_base.tscnis the scenery-free physical layout the dedicated server loads (clients still render the variantMatchSimnames). - 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 properties (mass, inertia, friction material) live inobjects/ship.tscn, not in_readyoverrides — keep the scene truthful; RL tuning and the client/server parity trace depend 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. - 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 runs work. - HUD / telemetry pattern:
Shipemits flight data via signals only when values change past thresholds (_last_*fields,*_THRESHOLDconstants).HUDController(scripts/HUDController.gd) 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, connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments, not hardcodedget_nodepaths or per-frame polling.HUDControllerduck-types on optional signals (timer_updated,match_ended,kickoff_countdown, …) and hides the corresponding widget when a mode lacks one — so declaring a signal you never emit is worse than not declaring it (it shows a permanently frozen timer instead of hiding it). SimConstants.TICK_HZis the single source of truth for the 60 Hz tick — every derived timing constant reads it rather than restating60. It is not wired toproject.godot'sphysics/common/physics_ticks_per_second(an engine setting), so those must be kept in sync by hand.- Input actions are defined in
Game/project.godotunder[input](move_forward,turbo,reset_ball,toggle_perf_overlayF3,toggle_net_overlayF4, …) and read only byPlayerShipControllerplus mode-level_unhandled_input— add new controls there rather than hardcoding key checks. - Physics engine is Jolt (
[physics] 3d/physics_engine="Jolt Physics").
project.godot hazard
Godot's ConfigFile writer does not round-trip comments, and a # block directly above a setting can be spliced into that setting's own line on rewrite, silently commenting it out. Do not add comments to Game/project.godot. This matters most for the feature overrides run/main_scene.training and run/main_scene.dedicated_server, which are how the training and server exports reach the right scene without a CLI flag; tests/cases/test_project_settings.gd fails loudly if they ever break.
Networking (server-authoritative, with client prediction)
All hot-path RPCs live on autoloads, never scene nodes, so RPC NodePaths never depend on which scene is loaded. The autoload chain, in project.godot order:
NetSim(net_sim.gd) — debug-only seeded latency/jitter/loss/duplicate decorator around outgoing dispatch. A pure passthrough unless--net-sim-latency=/--net-sim-jitter=/--net-sim-loss=/--net-sim-dup=/--net-sim-seed=are passed, so its mere existence changes nothing. Each process reads only its own flags, which is what makes asymmetric (e.g. lossy-upload-only) testing free.NetworkManager(network_manager.gd) — transport-neutral host/join/shutdown and connection signals. Setsserver_relay = falsethe moment a peer exists (the defaulttruewould let any client RPC any other client through the server). Runs manual multiplayer polling (set_multiplayer_poll_enabled(false)), because SceneTree's automatic poll runs on the idle frame and would cost a frame in each direction for RPCs issued from_physics_process— anything driving a connection must callNetworkManager.poll()itself or nothing is ever sent or received.MatchNet(match_net.gd) — hello/welcome handshake, strictprotocol_versionand tick-rate gating, and the roster (name, team, ready) that survives the lobby→match transition. Slot assignment is not stored here; it's derived at spawn time.MatchSim(match_sim.gd) — the Phase 2+ simulation RPCs:match_config,input(client→server),snapshot(server→client), score/state/kickoff/goal/clock messages. Also owns protocol-level input validation (leaky-bucket packet and byte rate limits), because framing abuse is independent of any particular match's state. Emitsinput_rejectedwith the verbatim bytes so the replay log can explain "my input did nothing".NetDebugOverlay(F4) andPerfOverlay(F3) — headless-guarded read-only overlays.
Supporting modules, deliberately standalone (RefCounted/class_name, no scene or RPC dependency) so they unit-test head-on:
net_codec.gd— the wire format.PROTOCOL_VERSION, channel intents, quantisers, pack/unpack for the input and snapshot packets. Any change here is a protocol change.match_state.gd— the match lifecycle enum and its legal-transition table. The integer values are the wire format (match_stateis a u8 in the snapshot header): never renumber an existing state, only append.net_body_state.gd,net_interpolator.gd,net_ship_predictor.gd,local_prediction_history.gd,local_input_timeline.gd,input_jitter_buffer.gd,input_lead_controller.gd,adaptive_input_depth_controller.gd,ship_action_codec.gd,replay_log.gd,server_config.gd,server_log.gd— each has a header comment explaining its role and the task it came from.
networked_match.gd (NetworkedMatch extends GameMode, ~2.4k lines) is where it all meets: the server simulates every slot and broadcasts 60 Hz snapshots; a client simulates only its own unfrozen slot with one real controller while every remote slot and the ball stay frozen and interpolated. Its scene has no Arena or HUD child — both are built in code once the arena is actually known (the server picks it, the client learns it from match_config), which is why it overrides _ready() entirely rather than using GameMode's arena-required-synchronously flow.
Server process: scenes/server_boot.tscn (server_boot.gd) is the shell — strict CLI parsing via ServerConfig (unknown flag or bad value refuses to start), structured logging via ServerLog, physics-overrun watchdog. ServerMatchLoop (server_match_loop.gd) is the actual match loop: wait for --min-players by roster, not raw peers, count down, load the next arena from the rotation, run the match, return to the lobby, repeat or drain at --max-matches. It parents itself to the scene-tree root, never current_scene, because change_scene_to_file frees the live scene and an orchestrator freed by its own transition can't orchestrate the next one.
Known-insecure, and the reason public hosting is gated: slot reclaim is keyed by display name, so anyone who knows a disconnected player's name can take their reserved slot. Verified Steam identity (Phase 7) is the fix. Don't expose a server to strangers before then.
Matchmaking control plane (server/, Go)
The only component outside the Godot project, and roughly a third of the codebase. Layered so policy is testable without a database and persistence without a network:
domain/(~3.2k lines) — pure policy, no I/O: matcher formation and rating tolerance, Glicko ratings and tiers, proposal/queue/match state machines, casual lineup and backfill selection, probe validation, join authorisations. Most behaviour worth asserting lives here and needs no fixture.ranked.go's arena list is checked againstarena_registry.gd(see Arena registry above).store/(~5.3k) — PostgreSQL boundaries. Every mutation goes throughRunSerializable; contention is expected rather than exceptional, so the retry budget and jittered backoff there are load-bearing, not decoration.api/(~2.5k) — HTTP surface and the outbox dispatchers.Serviceis a struct of optional providers, each nil-guarded into a 503, which is why a binary can look healthy while a whole feature is unreachable — check whatcmd/*/main.goactually assigns before concluding a feature is broken.allocator/,supervisor/,agones/— allocation, the Go process that wraps the exported Godot server in an Agones pod, and the Agones client.matcher/,workload/,observability/,steam/,testkit/— the matcher worker loop, workload-token signing, metrics, the Steam Web API adapter, and deterministic offline fakes.
cmd/ holds seven binaries: control-plane, matcher, allocator,
maintenance, game-server-supervisor, migrate, and testkit-api.
testkit-api is test-only — it injects a fake Steam login that accepts any
ticket, and must never be deployed in place of control-plane.
Three things that are easy to get wrong:
- Integration tests are behind
//go:build integrationand need a real PostgreSQL/Redis, sogo test ./...silently skips them. Run them throughscripts/run_*_integration.sh, which start their own disposable containers.go vet -tags integration ./...is worth running too, or those files rot uncompiled. - Config is start-time. Tier bands, the join-signing key set, Steam
credentials and the probe providers are all read once in
main(). Changing them is a rolling restart, not a hot reload — deliberate, and consistent with how everything else in these binaries is supplied. - The wire contract is versioned.
contracts/v1/openapi.jsonandstate-transitions.jsonare asserted bycontracts/v1/test_contracts.py; changing a status code or operation ID without updating them breaks generated clients silently.
Steam transport
net_transport.gd (NetTransport) is a deliberately narrow boundary: a transport only creates a peer; NetworkManager keeps ownership of polling, RPC policy and lifecycle. enet_transport.gd and steam_transport.gd implement it. NetworkManager.host()/join() default to "enet"; passing "steam" never falls back — a missing custom build or failed init returns an error naming the missing prerequisite (steam_bootstrap.gd produces those messages). Discovery and server advertisement are intentionally unimplemented until a project-owned App ID exists; the local default is Valve's Spacewar App ID 480, which must never be used to advertise servers or ship.
Reinforcement learning / AI bots
See TRAINING.md for the full workflow. Architecture summary:
scenes/training.tscn/training_elevated.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 training, in-game inference, and the server — never fork or diverge these 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/(promoted tiers inGame/bots/promoted/); Match mode'sbot_model_path/bot_reaction_ticks/bot_action_noiseexports configure the opponent, with the main menu'sGameSettingsautoload selections overriding them. - 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), plus the curriculum drivers (curriculum.py,generation5.py) and their JSON state files. - 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.