Files
CosmicClash/docs/TECH_STACK.md
T
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

356 lines
19 KiB
Markdown

# 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](https://github.com/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`](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](https://github.com/edbeeching/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.godot``3d/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.
- **Kustomize** — `deploy/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`](MATCHMAKING.md).