Add a Go-vs-C#/Rust/C++ rationale for the matchmaking control plane to TECH_STACK.md, and point at it from MATCHMAKING.md and README.md. Also correct CLAUDE.md and README.md, which still described the backend as unstarted/not built even though server/ has ~13k lines of Go across matcher, allocator, api, store, security, supervisor and agones.
14 KiB
Tech stack
What this project is built with, and why each piece was chosen, sourced from the project's own docs and code comments. Where the reasoning for a choice isn't recorded anywhere, this doc says so rather than guessing.
Engine: Godot 4.7
The whole game — client and dedicated server alike — is one Godot 4.7 project, GDScript only. There is no C#, ONNX, or .NET code involved at runtime anywhere in the shipped product.
Why Godot, specifically: legal, not technical. Per README.md's
"Legality" section, the concept of "vehicle soccer" cannot be copyrighted,
but Rocket League's specific expression of it can be. Building on Unreal or
Unity — the engines Psyonix and most Rocket-League-likes use — would invite
comparison to that specific expression. Using a different engine (Godot) and
different vehicles (space ships instead of cars) is a deliberate part of
keeping the project's own expression original and legally distinct.
Physics: Jolt Physics
Set via Game/project.godot's 3d/physics_engine="Jolt Physics" — Godot
4's alternative physics backend, not the engine's own default
(GodotPhysics3D). All ship and ball movement is force/torque-based
(_integrate_forces), never kinematic.
Why Jolt over Godot's default physics: not written down anywhere in the project's own docs, but per the project owner, the goal was a physics engine whose behavior isn't tied to Godot's own release cycle — so upgrading to a future major Godot version doesn't silently change how the game feels, the way an engine-version upgrade has repeatedly worried Rocket League's own playerbase (players have specifically flagged that Unreal Engine upgrades risk changing timestep/continuous-collision behavior enough to break muscle memory built over thousands of hours).
This holds up under scrutiny. GodotPhysics3D (Godot's built-in default) is
an internal engine subsystem, versioned and rewritten alongside Godot itself,
and has a real history of behavior changing across Godot releases — for
example a kinematic-body regression introduced between 4.3-dev4 and
4.3-dev5, and collision-detection differences reported across the 4.0 line.
Jolt, by contrast, is developed as an independent upstream library
(jrouwe/JoltPhysics) with its own
semantic versioning and a user base beyond just Godot, so its collision
behavior changes on its own release cadence rather than as a side effect of
unrelated Godot core work. It's a real reduction in coupling, not a
complete guarantee: Godot still pins (and can bump) a specific Jolt version
per release, and Godot's own Jolt integration layer can itself introduce
differences — e.g. a sleeping RigidBody3D wakes differently under Jolt
than under GodotPhysics3D when another body approaches it.
Separately documented, and a real consequence either physics backend would
share: Jolt is not bit-deterministic across platforms or even across
differing contact orderings on the same platform, and Godot exposes no world
snapshot/restore API. That fact is why the multiplayer architecture is
server-authoritative with client-side prediction of only the local ship,
rather than rollback/resimulation netcode — rollback would require
deterministic replay, which no physics engine choice here provides
(multiplayer-next.md §1, decision 1).
Multiplayer transport: Godot's built-in MultiplayerAPI over ENet
The default and fully-supported transport is ENetMultiplayerPeer —
Godot's built-in high-level multiplayer networking, direct-IP over UDP,
port 7777 by default. A thin NetTransport abstraction
(Game/scripts/net_transport.gd) exists specifically so a second transport
(Steam) can be swapped in without touching the rest of the networking code.
Design choices layered on top of the built-in peer, and why:
ENetMultiplayerPeer.server_relayis forced tofalse. It defaults totrue, which lets any clientrpc()any other client through the server — incompatible with a server-authoritative model. Called out inmultiplayer-next.md§2.1 as "the single highest-value one-line security change in the document."- Manual multiplayer polling, not Godot's automatic idle-frame poll.
NetworkManagercallsset_multiplayer_poll_enabled(false)because the automatic poll runs on the idle frame, which would tax every RPC issued from_physics_processup to a full frame in each direction — unacceptable for a physics-tick-rate game. - Server-authoritative simulation with client-side prediction of the local ship and ball only; no world rollback. Direct consequence of Jolt's non-determinism (see above).
- A custom binary wire format (
net_codec.gd) rather than raw RPC argument marshalling, for compact, quantised input/snapshot packets sent at high frequency — no stated alternative was considered in the docs, but the packet-size/channel-intent design inmultiplayer-next.md§2 is extensive and deliberate.
Optional multiplayer transport: Steam (GodotSteam)
Game/scripts/steam_transport.gd implements the same NetTransport
interface using SteamMultiplayerPeer over Steam's SDR (Steam Datagram
Relay), from a custom GodotSteam-patched Godot build (not stock Godot —
STEAM.md). It is entirely optional: the default build and every CI check
use ENet only, and a build without the steam feature is fully functional
without it.
Why it's optional and why raw ENet remains primary: multiplayer-next.md
states plainly that "Docker/VPS is the primary v1 deployment path. Raw ENet
self-hosting needs port forwarding, and SDR is Phase 7 — so [the ENet
phases] ship something that works on LAN or a VPS and nowhere else." Steam/SDR
is being added later specifically to remove the port-forwarding requirement
and to supply verified player identity — direct-IP ENet's slot-reclaim logic
is keyed by display name today, which is insecure against a public server
(see multiplayer-next.md).
Dedicated server hosting: Docker (primary) or native systemd
The dedicated server is not a separately-written service — it's the same
Godot project, exported headless (res://scenes/server_boot.tscn) via
Godot's own --export-release "Linux Dedicated Server" preset. Two
deployment paths are documented (SERVER.md):
- Docker, the primary path: a multi-stage
Dockerfilebuilds the export inside a pinnedbarichello/godot-ci:4.7.1image and produces a slimubuntu:24.04runtime image.make verify-phase6builds it, runs it, joins two independent client processes to it, and asserts on match/goal/ arena-rotation behaviour — this is also the entire Phase 6 GitHub Actions workflow. - Native systemd, for a VPS: copy the exported binary to
/opt/cosmic-clash, run it as a dedicatedcosmicclashservice user viadeploy/cosmic-clash-server.service.
Per the project owner, Docker was chosen as the primary path for three
reasons: it gives a pinned, reproducible build environment that behaves
identically across local development, CI, and hosted production servers
(rather than three separately-drifting setups); it's portable across
hosting providers instead of assuming a specific Linux distro/init system
the way the systemd unit does; and it's the tooling the team is already
most familiar with. That matches what's independently visible in the repo —
SERVER.md documents Docker as the one path CI actually exercises
(make verify-phase6), while the systemd unit is native-deployment
documentation only, with no automated verification of its own.
Matchmaking control plane: Go, PostgreSQL, Redis, Agones
The one part of the project that is not the Godot project. server/ is a Go
module (~13k lines of non-test code across matcher, allocator, api,
store, security, supervisor, agones, migrations, observability)
implementing the casual/ranked queue design in
MATCHMAKING.md, plus a small PID-1 supervisor that exists
because Godot/GDScript cannot intercept SIGTERM and Agones needs a graceful
drain signal to land somewhere.
Why Go, and why "performance" is the wrong reason to give: the control plane is not in the simulation hot path. Physics, snapshots and 60 Hz input all live in the Godot dedicated server over ENet/SDR (see the transport sections above); Go never touches a game packet. Its actual workload is many mostly-idle WebSocket connections, a matcher loop that runs on a sub-second tick, and I/O against PostgreSQL, Redis and the Kubernetes API. That is I/O- and concurrency-bound, not CPU-bound, so the raw single-thread speed a systems language would buy is spent on work this service doesn't do. What actually drove the choice:
- Agones and Kubernetes are Go-native. Allocation, the GameServer SDK and
the k8s client are all first-party Go. Any other language means hand-rolling
REST against the Agones allocation service — see
server/agones/, which uses those clients directly. - Goroutines plus
contextare the right shape for the problem — many concurrent idle connections, a few periodic loops, and cancel-everything-on- shutdown semantics that the PID-1 supervisor depends on. - The surrounding operational ecosystem is Go — Prometheus instrumentation
(
server/observability/), structured logging, migrations, and the provider-portable deployment tooling. - Static binaries and slim containers, which matters for the supervisor and for keeping the allocated game-server image close to the existing one.
Alternatives, honestly weighed: Rust or C++ would be the correct answer for a custom UDP relay or the simulation server itself, and buy nothing measurable for a queue-and-allocate service — while costing significantly in iteration speed. C# is the only serious contender (ASP.NET Core is fast, its async model is excellent, and Postgres/Redis/WebSocket support is mature); it loses on the Agones/Kubernetes side, where the clients are community-maintained rather than first-party, and on container weight. TypeScript or Python would prototype faster but fit poorly for a service whose failure modes are almost entirely races and timeouts. None of those gaps is large enough to justify rewriting the Go that already exists.
AI opponents: reinforcement learning, trained out-of-process, run in pure GDScript
Two entirely separate pieces, deliberately joined only at a JSON file:
- Training (Python, not shipped): Godot RL Agents
(
godot-rl==0.8.2, vendored bridge plugin atGame/addons/godot_rl_agents, MIT-licensed) drives self-play PPO via Stable-Baselines3 (==2.4.0) over PyTorch (==2.13.0) and Gymnasium (==1.0.0), against a headless instance of the actual game (training/train.pylaunches real parallelgodot --headlessprocesses from source — the training environment is the game, not a reimplementation of its physics).training/requirements.txtpins these versions strictly, because the training code (ship_action_codec.gd,train.py) depends on specific library-internal behaviour (godot_rl's discrete-action-space branch, SB3's logit layout, Gymnasium's dict-key sorting) that an unpinned upgrade could silently change mid-curriculum. - In-game inference (
Game/scripts/policy_network.gd): the trained checkpoint is exported to a small JSON file (training/export_policy.py) and evaluated at runtime by a hand-written, dependency-free GDScript MLP.Game/addons/godot_rl_agents/VENDORED.mdnotes the plugin's ONNX/C# files are present upstream but unused here — they require the .NET Godot build, which this project does not use.
Why this split, rather than shipping ONNX/.NET inference: stated
directly in TRAINING.md — "the trained policy is exported to a small JSON
file and runs inside the game in pure GDScript — shipped bots need no
Python, no .NET, no network." Keeping the shipped game GDScript-only (no
.NET Godot build) is consistent with the rest of the stack.
Tooling (not shipped with the game)
mcp/godot-mcp(git submodule, Node/TypeScript) — drives a live Godot editor/runtime instance for AI-assisted development; not part of the game.mcp/blender-mcp(git submodule, Python/uv) — drives a live Blender instance for generating original 3D assets (ships, arenas), for the same originality reasons covered under "Why Godot" above.tools/blender/,tools/textures/— standalone Python scripts (Blender's embedded Python, plus texture generators) used to produce the project's original meshes and textures.
What's deliberately absent
- No C# or .NET runtime anywhere in the shipped game or server. The
"C# backend" an early version of
README.mddescribed was never built — that wording is long gone from the README itself. A backend service does now exist for matchmaking, but it is Go, not C# — that framing predates every real decision here. See "Matchmaking control plane" above for why Go was chosen over C# and over Rust/C++. - No HTTP/WebSocket/gRPC layer for simulation traffic — the live game uses
ENet/Steam SDR over UDP via Godot's own
MultiplayerAPI. The matchmaking control plane now has an authenticated Go REST/WebSocket boundary for queue, proposal, assignment and recovery traffic; simulation remains on ENet/SDR. - No ONNX or other ML runtime in the shipped game — see "AI opponents" above.
Planned, not yet built
- The remaining Go matchmaking control-plane deployment — independently
runnable matcher, allocator and maintenance roles backed by PostgreSQL and
Redis, deployed on provider-portable Kubernetes with Agones-managed game
fleets. The authenticated API boundary exists; durable production wiring and
provider deployment remain. The cloud provider remains deliberately
replaceable; the application stack is locked.
This is a 1.0 launch blocker and the single largest departure from "one
Godot project, no backend". See
MATCHMAKING.md.