mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
fe453ab607
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.
356 lines
19 KiB
Markdown
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).
|