Files
Josh Creek fe453ab607 docs: add a version inventory to TECH_STACK.md, refresh stale claims
TECH_STACK.md explained why each choice was made but never listed what is
actually pinned, so there was no single place to answer "what version of X do
we use". Adds a Version inventory section covering the shipped game, the Go
control plane's four direct dependencies, the datastore/platform versions and
the exactly-pinned training stack, plus a Verification toolchain subsection for
the Make/Docker/kind/Kustomize/Actions harness.

Also corrects two things the doc had outgrown: the allocation pipeline is now
wired end to end and gated in CI, so only the provider deployment remains; and
the Steam section covered only the GodotSteam client transport, omitting the
server-side Web API ticket verifier in server/steam.
2026-09-05 23:16:10 +01:00

19 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_SPEC.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_relay is forced to false. It defaults to true, which lets any client rpc() any other client through the server — incompatible with a server-authoritative model. Called out in MULTIPLAYER_SPEC.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. NetworkManager calls set_multiplayer_poll_enabled(false) because the automatic poll runs on the idle frame, which would tax every RPC issued from _physics_process up 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 in MULTIPLAYER_SPEC.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: per multiplayer-next.md, Docker/VPS is the primary v1 deployment path, and raw ENet self-hosting needs port forwarding while SDR is Phase 7 — so the ENet phases ship something that works on LAN or a VPS today, and nowhere else yet. 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 (multiplayer-next.md §0, known defect C).

There is a second, independent use of Steam that does not involve GodotSteam at all: server/steam/ verifies session tickets server-side against Valve's ISteamUserAuth/AuthenticateUserTicket Web API over plain HTTP, which is what turns a claimed identity into a trusted one for matchmaking and for slot reclaim. It distinguishes "Valve rejected this ticket" (401) from "Valve is unreachable" (503) so an outage cannot be mistaken for an authentication failure, and refuses family-shared and banned accounts. It needs a publisher Web API key, which is a server-side secret and must never reach a client.

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 Dockerfile builds the export inside a pinned barichello/godot-ci:4.7.1 image and produces a slim ubuntu:24.04 runtime image. make verify-phase6 builds 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 dedicated cosmicclash service user via deploy/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 context are 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 at Game/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.py launches real parallel godot --headless processes from source — the training environment is the game, not a reimplementation of its physics). training/requirements.txt pins 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.md notes 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.

Version inventory

Everything the project actually pins, in one place. The rest of this document explains why these were chosen; this is what is in use. Versions here are the source of truth's values at the time of writing — when they disagree with the files named, the files win.

Shipped game and dedicated server

Thing Version Pinned in
Godot 4.7.1 Dockerfile (digest-pinned barichello/godot-ci)
Physics Jolt Game/project.godot3d/physics_engine="Jolt Physics"
Runtime dependencies none pure GDScript; no .NET, no ONNX, no native extensions in the default build
GodotSteam custom build, opt-in steam-dependencies.lock.json

The shipped client and server carry no third-party runtime dependency at all in the default ENet build. That is a deliberate constraint, not an accident of scope — see "What's deliberately absent".

Matchmaking control plane (Go)

Thing Version Notes
Go 1.23 server/go.mod
jackc/pgx/v5 5.7.4 PostgreSQL driver; used through database/sql for pooling, and directly for LISTEN/NOTIFY, which needs a dedicated session
redis/go-redis/v9 9.7.0 transient candidate index only; the durable queue is PostgreSQL
alicebob/miniredis/v2 2.38.0 test-only in-process Redis

Four direct dependencies, three of them drivers. There is no web framework, no ORM, no DI container and no code generation: HTTP is net/http with a hand- written mux (server/api/service.go), SQL is hand-written, and migrations are numbered .sql files under server/migrations/ — each with a down/ counterpart — applied by the cmd/migrate binary. That is a deliberate choice about a service whose whole job is a small number of carefully-fenced transactions.

Rating maths is Glicko-2, implemented in server/domain/rating.go rather than taken from a library.

Datastores and platform

Thing Version Pinned in
PostgreSQL 17 (alpine) compose.*.yml, scripts/run_*_integration.sh
Redis 7 (alpine) compose.*.yml, scripts/run_redis_integration.sh
Agones 1.49.0 scripts/verify_kind_agones.sh (AGONES_VERSION)
Kubernetes 1.33 in CI kindest/node:v1.33.1
Manifests Kustomize deploy/k8s/base + overlays/{eu,na}
Metrics Prometheus deploy/observability/ — ServiceMonitors and PrometheusRules

Container images are referenced by digest, never by tag; scripts/verify_supply_chain.py fails the build on any mutable reference. All six digests under deploy/ are currently all-zero placeholders, and the ghcr.io/cosmic-clash/* registry namespace does not exist yet — publishing the images is the open work tracked in issue #31, and is the last thing standing between the manifests and a real deployment.

Training (out-of-process, not shipped)

Thing Version
Python 3.12
godot-rl 0.8.2
stable-baselines3 2.4.0
torch 2.13.0
gymnasium 1.0.0
tensorboard 2.21.0

Pinned exactly, and training/requirements.txt explains why in unusual detail: the curriculum depends on specific library internals rather than documented public APIs, so an unpinned reinstall could silently change behaviour partway through a 12-hour training stage. None of this ships — the game runs exported policies through a pure-GDScript MLP.

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.

Verification toolchain

Everything is driven from Makefile targets so that CI and a local run are the same command:

  • GNU Make — the single entry point (verify-phase6, verify-enet-integration, verify-kind-agones, verify-supply-chain, …).
  • Docker and Docker Compose — the multi-process gates. The game gates use a staged Dockerfile; the control-plane gates use compose.*.yml fixtures.
  • kind (kindest/node:v1.33.1) and Helm — a throwaway Kubernetes cluster with Agones installed, for the allocation gate.
  • Kustomizedeploy/k8s/base plus overlays/{eu,na}, validated by kubectl kustomize in CI rather than only at deploy time.
  • GitHub Actions — eight workflows under .github/workflows/, each one a thin wrapper around a Make target, with path filters so a docs-only change doesn't spin up a Kubernetes cluster.
  • scripts/verify_supply_chain.py — fails the build on any mutable image reference, which is why every manifest pins by digest.

Godot itself has no build step and no linter — the project runs from source, so "the tests pass" is the only mechanical check that exists on the GDScript side.

What's deliberately absent

  • No C# or .NET runtime anywhere in the shipped game or server. The "C# backend" an early version of README.md described 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 durable wiring now exists end to end — queue, latency probes, proposal, allocation, signed assignment rosters and result submission — and is exercised by Compose and kind/Agones gates in CI. What remains is the provider deployment itself: a registry to publish the images to, and a live cluster. 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.