mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-17 15:02:01 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aa0f5b9c2 | |||
| 964094f65a |
@@ -6,10 +6,24 @@ Important rule: never create co-authored commits. Never mention Claude in commit
|
|||||||
|
|
||||||
## Project overview
|
## 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 project is GDScript/Godot only right now — the "C# backend" mentioned in README.md is planned but not yet started. There is no server-side code; the MVP is local-only play against bots.
|
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. It is GDScript/Godot only today — the "C# backend" in README.md was never started, and the dedicated server is an export of this same Godot project. A **separate backend service is now planned** (not started) for casual/ranked matchmaking, which is a 1.0 launch blocker; see `docs/MATCHMAKING.md`. 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.
|
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 current** multiplayer checklist. Short. Read this first for "what's left".
|
||||||
|
- `multiplayer-todo.md` — 250 KB of historical design decisions, per-task implementation evidence, and §9's numbered "gotchas" list. Code comments cite it constantly by section/task number (`§2.4`, `task 5.10`); when a comment does, that section is the real explanation. Don't add new work here — it's the archive.
|
||||||
|
- `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. Not implemented; a 1.0 launch blocker, and the reason a backend service now exists in the plan.
|
||||||
|
- `docs/TECH_STACK.md` — what the project is built with and why.
|
||||||
|
- `TODO.md` — deferred non-multiplayer work (audio is the big one: there is none at all).
|
||||||
|
|
||||||
## Godot MCP server
|
## Godot MCP server
|
||||||
|
|
||||||
This repo vendors [godot-mcp](https://github.com/tugcantopaloglu/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.
|
This repo vendors [godot-mcp](https://github.com/tugcantopaloglu/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.
|
||||||
@@ -55,35 +69,131 @@ Upstream ships telemetry, and there are **two independent switches** — turning
|
|||||||
|
|
||||||
There is no build step or linter for the GDScript project itself — Godot projects run directly from source.
|
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 run `godot --path Game` from the repo root.
|
- **Open the project**: open `Game/` as a project in the Godot 4.7 editor, or run `godot --path Game` from the repo root.
|
||||||
- **Run the game**: press Play in the editor, or `godot --path Game res://scenes/main_menu.tscn`.
|
- **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`.
|
- **Headless smoke test** (RL/CI precondition — the game must run without rendering): `godot --headless --path Game res://scenes/free_play.tscn`.
|
||||||
- **Unit tests** (pure-function assertions, see `multiplayer-todo.md` task 1.0): `godot --headless --path Game res://tests/test_runner.tscn`. 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.
|
- **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/imported` half-generated (see the Dockerfile comment).
|
||||||
- **Networking smoke tests** (real two-process ENet connect/disconnect, see `multiplayer-todo.md` §7 Phase 1 tasks): each starts a host then a client, each in its own `godot --headless` process, printing `SMOKE PASS/FAIL: ...` and exiting 0/1. Not part of `test_runner.tscn` — a live ENet handshake needs two real processes. `res://tests/net_smoke.tscn` (task 1.2 — `--role=host|client`, now also confirms the host observes `client_disconnected`, not just that each side exits cleanly on its own), `res://tests/match_net_smoke.tscn` (task 1.4 — `--role=host|client|client-badversion|client-longname|host_recycle`; `client-longname` sends an oversized player name and expects rejection, `host_recycle` hosts, lets a client join, leaves, re-hosts, and confirms the roster is actually empty — run a plain `client` role against it), `res://tests/clock_smoke.tscn` (task 1.8 — `--role=host|client`, clock convergence cross-checked against independent OS-wall-clock ground truth, not just self-consistency), `res://tests/lobby_smoke.tscn` (task 1.5 — `--role=host|client`; **both** roles load `lobby.tscn` for real via `change_scene_to_file` now, exercising the server's read-only view as well as the client's interactive one). See `network_manager.gd`'s header comment and `multiplayer-todo.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** (task 1.7) 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.
|
### 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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-todo.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)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 exactly two jobs, both running the Make targets above: `dedicated-server-smoke.yml` (`make verify-phase6`) and `enet-integration.yml` (`make verify-enet-integration` inside the `enet-test` image). There is no unit-test-only workflow — `verify-phase6` runs `test_runner.tscn` as its first step.
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
- The `mcp/godot-mcp` submodule is a separate Node/TypeScript project with its own `npm install` / `npm run build` (see above) — it is tooling, not part of the game itself.
|
- The `mcp/godot-mcp` submodule is a separate Node/TypeScript project with its own `npm 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.gd` for reading server replay logs); `tools/blender/` and `tools/textures/` hold the Python generators for the original ship/ball/nebula assets.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The structure was deliberately chosen so an RL-trained AI opponent and, later, multiplayer bolt on without rework (see `TODO.md` for the deferred work). The three load-bearing seams are the controller abstraction, the arena/game-mode split, and code-driven spawning.
|
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.
|
||||||
|
|
||||||
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `scenes/free_play.tscn` (practice: no timer, R resets ball) or `scenes/match.tscn` (150s timer, per-team score, kickoff resets). Esc returns to the menu from either mode.
|
### Core game
|
||||||
- **Controller seam (do not bypass)**: `Ship` (`scripts/ship.gd`, `RigidBody3D`) never reads `Input`. Each physics tick, `_integrate_forces` pulls one `ShipAction` (`scripts/ship_action.gd`: thrust `Vector3`, rotation `Vector3`, turbo `bool`, each axis -1..1) from its `ShipController` child (`scripts/ship_controller.gd`, base returns a zero action). `PlayerShipController` reads input actions; a future `AIShipController` (RL policy) or network-replication controller implements the same `get_action()` interface. A ship with no controller is inert but simulated. The ShipAction shape *is* the future RL action space — change it deliberately.
|
|
||||||
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`: floor/walls/ceiling colliders), two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (`INNER_HALF_X` 18, `INNER_HALF_Z` 27, `INNER_HEIGHT` 18, `GOAL_LINE_Z` = `INNER_HALF_Z`) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`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. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
|
- **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 in `scripts/scene_paths.gd`; use those constants rather than string literals.
|
||||||
|
- **Controller seam (do not bypass)**: `Ship` (`scripts/ship.gd`, `RigidBody3D`) never reads `Input`. Each physics tick, `_integrate_forces` pulls one `ShipAction` (`scripts/ship_action.gd`: thrust `Vector3`, rotation `Vector3`, turbo `bool`, each axis -1..1) from its `ShipController` child (`scripts/ship_controller.gd`, base returns a zero action). `PlayerShipController` reads input actions; `AIShipController` runs an RL policy; `RLShipController` is driven by the training bridge; `LocalNetShipController` wraps 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 enclosing `Boundary` (instance of `objects/arena_boundary.tscn`), two `Goal` instances, `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (`INNER_HALF_X` 18, `INNER_HALF_Z` 27, `INNER_HEIGHT` 18, `GOAL_LINE_Z` = `INNER_HALF_Z`) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`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.gd` is the single source of truth for the arena list — three settings × floor/elevated goal variants. `"random": true` gates which arenas Match/Spectate/the dedicated server may pick; **elevated-goal variants are Free-Play-only** until a checkpoint trained on `training_elevated.tscn` is 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. `arena_base.tscn` is the scenery-free physical layout the dedicated server loads (clients still render the variant `MatchSim` names).
|
||||||
- **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces it (`_handle_goal_scored`) and modes decide consequences. Never put scoring/reset logic in the goal.
|
- **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces 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 formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing flight model. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning depends on it.
|
- **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 in `objects/ship.tscn`, not in `_ready` overrides — 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) that `Ship` and `Ball` (`scripts/ball.gd`) each apply in their own `_integrate_forces` with independently-tuned strength/range, discovered via the `"arena_boundary"` group — enabling wall-rides and ceiling shots with no collision-shape changes. Because it runs inside `Ship`'s shared `_integrate_forces`, it reaches trained bots too; see `TRAINING.md` for the retrain this warrants.
|
- **Surface pull (wall/ceiling grav-plating)**: `ArenaBoundary.get_surface_pull()` is a wall+ceiling-only proximity force field (the floor stays plain default gravity) that `Ship` and `Ball` (`scripts/ball.gd`) each apply in their own `_integrate_forces` with 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 a `target` ship — ships have no camera/HUD dependency, so headless RL runs work (`godot --headless`).
|
- **Camera** (`scenes/ship_camera_rig.tscn`, `scripts/ship_camera.gd`, group `"ship_camera"`) is spawned by the game mode and given a `target` ship — ships have no camera/HUD dependency, so headless runs work.
|
||||||
- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd` on `scenes/HUD.tscn`, instanced by each mode's scene) discovers the camera rig and game mode via groups (`"ship_camera"`, `"game"`) but receives its target ship directly from the game mode via `GameMode.spawn_camera_rig` (mirroring the camera rig's `target`, not group lookup — the `"ship"` group can have 2+ members), connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments or cross-node communication, not hardcoded `get_node` paths or per-frame polling.
|
- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `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 via `GameMode.spawn_camera_rig`, connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments, not hardcoded `get_node` paths or per-frame polling. **`HUDController` duck-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).
|
||||||
- **Input actions** are defined in `Game/project.godot` under `[input]` (`move_forward`, `turn_left`, `turbo`, `reset_ball`, etc.) and read only by `PlayerShipController` (plus mode-level `_unhandled_input` for `reset_ball`/`ui_cancel`) — add new controls there rather than hardcoding key checks.
|
- **`SimConstants.TICK_HZ`** is the single source of truth for the 60 Hz tick — every derived timing constant reads it rather than restating `60`. It is *not* wired to `project.godot`'s `physics/common/physics_ticks_per_second` (an engine setting), so those must be kept in sync by hand.
|
||||||
- Physics engine is Jolt (`Game/project.godot`, `[physics] 3d/physics_engine="Jolt Physics"`).
|
- **Input actions** are defined in `Game/project.godot` under `[input]` (`move_forward`, `turbo`, `reset_ball`, `toggle_perf_overlay` F3, `toggle_net_overlay` F4, …) and read only by `PlayerShipController` plus 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. Sets `server_relay = false` the moment a peer exists (the default `true` would 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 call `NetworkManager.poll()` itself or nothing is ever sent or received.
|
||||||
|
- **`MatchNet`** (`match_net.gd`) — hello/welcome handshake, strict `protocol_version` and 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. Emits `input_rejected` with the verbatim bytes so the replay log can explain "my input did nothing".
|
||||||
|
- **`NetDebugOverlay`** (F4) and **`PerfOverlay`** (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_state` is 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.
|
||||||
|
|
||||||
|
### 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
|
## Reinforcement learning / AI bots
|
||||||
|
|
||||||
See `TRAINING.md` for the full workflow (training, exporting, evaluating, difficulty tiers). Architecture summary:
|
See `TRAINING.md` for the full workflow. Architecture summary:
|
||||||
|
|
||||||
- `scenes/training.tscn` (`scripts/training_mode.gd`, extends `GameMode`) is the headless self-play environment: two ships driven by `RLShipController`s, with `ShipAIController` (extends the vendored plugin's `AIController3D`) as the only class touching godot_rl types. The plugin is vendored (not a submodule) at `Game/addons/godot_rl_agents` — see its `VENDORED.md`; its C#/ONNX files are unused.
|
- `scenes/training.tscn` / `training_elevated.tscn` (`scripts/training_mode.gd`, extends `GameMode`) is the headless self-play environment: two ships driven by `RLShipController`s, with `ShipAIController` (extends the vendored plugin's `AIController3D`) as the only class touching godot_rl types. The plugin is vendored (not a submodule) at `Game/addons/godot_rl_agents` — see its `VENDORED.md`; its C#/ONNX files are unused.
|
||||||
- `scripts/ship_observations.gd` is the shared observation builder used by both training and in-game inference — never fork or diverge these two paths. Team 1's observations are mirrored (180° about Y) so one policy plays both sides.
|
- `scripts/ship_observations.gd` is 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` (a `ShipController`) runs the exported policy JSON via `scripts/policy_network.gd` (pure-GDScript MLP) — no .NET/ONNX/Python at runtime. Models live in `Game/bots/`; Match mode's `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` exports configure the opponent.
|
- In-game bots: `scripts/ai_ship_controller.gd` (a `ShipController`) runs the exported policy JSON via `scripts/policy_network.gd` (pure-GDScript MLP) — no .NET/ONNX/Python at runtime. Models live in `Game/bots/` (promoted tiers in `Game/bots/promoted/`); Match mode's `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` exports configure the opponent, with the main menu's `GameSettings` autoload 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, appends `training/eval_history.json`).
|
- 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, appends `training/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._decide` consumes exported policies in sorted order; change the action space only deliberately and everywhere together.
|
- 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._decide` consumes exported policies in sorted order; change the action space only deliberately and everywhere together.
|
||||||
|
|||||||
+5
-22
@@ -25,22 +25,6 @@ run/main_scene.dedicated_server="res://scenes/server_boot.tscn"
|
|||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
GameSettings="*res://scripts/game_settings.gd"
|
GameSettings="*res://scripts/game_settings.gd"
|
||||||
VideoSettings="*res://scripts/video_settings.gd"
|
VideoSettings="*res://scripts/video_settings.gd"
|
||||||
BackgroundFPS="*res://scripts/background_fps.gd"
|
BackgroundFPS="*res://scripts/background_fps.gd"
|
||||||
@@ -143,7 +127,6 @@ roll_right={
|
|||||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
|
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle_perf_overlay={
|
toggle_perf_overlay={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
@@ -164,15 +147,15 @@ toggle_net_overlay={
|
|||||||
|
|
||||||
[physics]
|
[physics]
|
||||||
|
|
||||||
|
common/physics_jitter_fix=0.0
|
||||||
3d/physics_engine="Jolt Physics"
|
3d/physics_engine="Jolt Physics"
|
||||||
common/physics_interpolation=true
|
common/physics_interpolation=true
|
||||||
common/physics_jitter_fix=0.0
|
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
anti_aliasing/quality/msaa_3d=2
|
|
||||||
anti_aliasing/quality/screen_space_aa=1
|
|
||||||
anti_aliasing/quality/use_debanding=true
|
|
||||||
lights_and_shadows/positional_shadow/atlas_size=2048
|
|
||||||
lights_and_shadows/directional_shadow/size=2048
|
lights_and_shadows/directional_shadow/size=2048
|
||||||
|
anti_aliasing/quality/msaa_3d=2
|
||||||
|
anti_aliasing/quality/use_debanding=true
|
||||||
|
anti_aliasing/quality/screen_space_aa=1
|
||||||
|
lights_and_shadows/positional_shadow/atlas_size=2048
|
||||||
lights_and_shadows/soft_shadow_filter_quality=2
|
lights_and_shadows/soft_shadow_filter_quality=2
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://505pjuvqynm0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cjij4dxir0qxd
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://itgcxadtf1wd
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c117m546w6u30
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cklv8t2htg2gf
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dtqbio4ob00hq
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dvwivtlgx2f34
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bf7o5x2nug4br
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://by4f1ydqsm0ry
|
||||||
@@ -16,10 +16,9 @@ The concept of 'vehicle soccer' cannot be copyrighted, but the original expressi
|
|||||||
|
|
||||||
## Technical Information
|
## Technical Information
|
||||||
|
|
||||||
This project is composed of two parts:
|
Cosmic Clash is a single Godot 4.7 project, written entirely in GDScript. That same project exports both the interactive game and a headless dedicated server for online multiplayer. See [`docs/TECH_STACK.md`](docs/TECH_STACK.md) for the full stack and the reasoning behind each choice.
|
||||||
|
|
||||||
1. Godot game
|
Online play with casual and ranked queues is a 1.0 requirement, and it needs a small backend service for identity, matchmaking and ratings — separate from the Godot project, and not yet built. See [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md).
|
||||||
2. C# backend
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ The largest gap between this and a AAA-feeling product is presentation, not code
|
|||||||
|
|
||||||
- [ ] **Audio — there is none.** Zero sound files, zero `AudioStreamPlayer` nodes, no bus layout. Needs: engine hum pitched to throttle, turbo whoosh, ball impacts scaled by collision impulse, wall scrapes, goal explosion, crowd bed, UI clicks, countdown beeps, music. Can be driven off `Ship`'s existing telemetry signals.
|
- [ ] **Audio — there is none.** Zero sound files, zero `AudioStreamPlayer` nodes, no bus layout. Needs: engine hum pitched to throttle, turbo whoosh, ball impacts scaled by collision impulse, wall scrapes, goal explosion, crowd bed, UI clicks, countdown beeps, music. Can be driven off `Ship`'s existing telemetry signals.
|
||||||
- [ ] Custom font + a real `Theme` resource for the HUD. `ThemeDB.fallback_font` at 10-13 px reads as a debug overlay.
|
- [ ] Custom font + a real `Theme` resource for the HUD. `ThemeDB.fallback_font` at 10-13 px reads as a debug overlay.
|
||||||
|
- [ ] **Video settings menu is missing graphics presets, vsync, and resolution scaling.** `video_settings.gd` currently exposes only AA, glow, and brightness, while SDFGI, SSIL, SSAO, and five shadow-casting lights are on by default and unreachable by the player. Blocked on the same profiling gate as the multiplayer section's 0.16–0.28 tasks below (0.17/0.17b/0.17c/0.17d) — needs a human at the editor with real hardware, not further code changes on its own.
|
||||||
|
|
||||||
## Multiplayer (long term)
|
## Multiplayer (long term)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Matchmaking — casual and ranked queues
|
||||||
|
|
||||||
|
Design scope for online casual and ranked play. This is a **1.0 launch
|
||||||
|
blocker**, not a post-launch addition.
|
||||||
|
|
||||||
|
Nothing described here is implemented yet. This doc exists to record the
|
||||||
|
decisions and the reasoning before code is written; per-task implementation
|
||||||
|
evidence belongs in `multiplayer-todo.md` once work starts, and the live
|
||||||
|
checklist lives in [`multiplayer-next.md`](../multiplayer-next.md).
|
||||||
|
|
||||||
|
## The model change
|
||||||
|
|
||||||
|
The multiplayer that exists today is a **community-server** model. A
|
||||||
|
dedicated server runs forever: it waits for `--min-players` by roster,
|
||||||
|
counts down `--start-countdown`, loads the next arena from the rotation,
|
||||||
|
plays a match, returns to the lobby, and repeats (`server_match_loop.gd`).
|
||||||
|
Players reach it by direct IP, and after Phase 7 by a Steam server browser.
|
||||||
|
The server is the durable thing and players come and go around it.
|
||||||
|
|
||||||
|
Queued matchmaking inverts that. Players are the durable thing: they enter a
|
||||||
|
queue, a matchmaker groups them by rating and region, and a **server is
|
||||||
|
allocated for that one match** and torn down afterwards. Both models can
|
||||||
|
coexist — community servers via the browser, queues via the matchmaker — and
|
||||||
|
they should, because the server browser is already most of the way to done.
|
||||||
|
|
||||||
|
## Hard prerequisite: verified identity
|
||||||
|
|
||||||
|
Ranked cannot ship before Phase 7's Steam auth tickets.
|
||||||
|
|
||||||
|
Slot reclaim is currently keyed by **display name** (see
|
||||||
|
`--slot-reservation-seconds`, and the known-issues list in
|
||||||
|
`multiplayer-next.md`). A rating attached to a spoofable identity is worse
|
||||||
|
than no rating at all: it is trivially farmed, and it invites players to
|
||||||
|
invest in a ladder that cannot be defended. "Ranked is critical" therefore
|
||||||
|
*raises* the priority of Steam identity rather than routing around it.
|
||||||
|
|
||||||
|
Casual queueing has a weaker requirement — it still needs stable identity for
|
||||||
|
abandon penalties and ban enforcement, but the cost of a compromise is lower.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Decided: **Steam for identity, a project-owned backend for everything else.**
|
||||||
|
|
||||||
|
This reverses the "no backend" position stated in
|
||||||
|
[`TECH_STACK.md`](TECH_STACK.md) and `README.md`, which described the state
|
||||||
|
of the project before matchmaking was scoped. The dedicated server remains a
|
||||||
|
Godot export; the new service is separate from it.
|
||||||
|
|
||||||
|
The alternative — Steam-native matchmaking (lobbies plus Leaderboards or User
|
||||||
|
Stats as the rating store) — was rejected on two grounds. Steam lobby
|
||||||
|
matchmaking has no real concept of a skill distribution to match against, and
|
||||||
|
Leaderboards are a display surface rather than a rating store with the
|
||||||
|
transactional guarantees a ladder needs. It would also permanently bind the
|
||||||
|
game to Steam, foreclosing other platforms.
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
| Component | Runs where | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Steam auth ticket validation | backend | Turn a client-supplied ticket into a verified SteamID via the Steamworks Web API. The only trusted source of identity. |
|
||||||
|
| Queue / matchmaker | backend | Hold queued players per playlist and region; form matches on rating proximity with a widening tolerance over wait time. |
|
||||||
|
| Rating store | backend (DB) | Per-identity, per-playlist rating and match history. Written only by the backend, never by a game client. |
|
||||||
|
| Server allocator | backend | Start a dedicated-server instance per formed match, hand its address to the matched clients, reclaim it on exit. |
|
||||||
|
| Dedicated server | Godot export | Unchanged simulation. Gains a mode where the roster is *assigned* rather than open, and reports a result at the end. |
|
||||||
|
| Game client | Godot | Queue UI, estimated wait, accept/decline, connect-on-assignment, post-match rating delta. |
|
||||||
|
|
||||||
|
### What already exists and gets reused
|
||||||
|
|
||||||
|
The server side needs less new work than it looks:
|
||||||
|
|
||||||
|
- **`--max-matches=1`** already makes the server drain and `exit(0)` after a
|
||||||
|
single match. That is precisely the lifecycle a per-match allocator wants;
|
||||||
|
it was built for CI, and it generalises for free.
|
||||||
|
- **`ServerConfig`** is a single-source-of-truth flag table with strict
|
||||||
|
validation — new allocation flags are declared in one place and are
|
||||||
|
automatically parsed, type-checked, config-file-backed and documented.
|
||||||
|
- **`--min-players` / `--start-countdown` / `--slot-reservation-seconds`**
|
||||||
|
are the match-formation primitives, and they already count *roster*
|
||||||
|
members rather than raw peers.
|
||||||
|
- **`MatchNet`'s roster** already survives the lobby→match transition, which
|
||||||
|
is the structure an assigned roster slots into.
|
||||||
|
- **`MatchState`** already has a legal-transition table with wire-stable
|
||||||
|
integer values, so new lifecycle states append cleanly.
|
||||||
|
|
||||||
|
### What is genuinely new
|
||||||
|
|
||||||
|
- The backend service itself (process, deploy, DB, ops) — nothing like it
|
||||||
|
exists in this repo today.
|
||||||
|
- Server-authoritative **match results**: the dedicated server must report
|
||||||
|
the outcome to the backend over a channel a client cannot forge. This is
|
||||||
|
the first non-ENet/SDR network path in the project (see TECH_STACK's "no
|
||||||
|
HTTP layer" note, which this supersedes).
|
||||||
|
- An **assigned-roster** server mode: only the matched SteamIDs may take a
|
||||||
|
slot, replacing the current first-come model.
|
||||||
|
- Client-side queue UI and the accept/decline flow.
|
||||||
|
|
||||||
|
## Casual vs ranked
|
||||||
|
|
||||||
|
They are different playlists, not a difficulty toggle, and their rules
|
||||||
|
diverge in ways that affect the server:
|
||||||
|
|
||||||
|
| | Casual | Ranked |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Rating | Hidden, used only for matching | Visible, with tiers |
|
||||||
|
| Backfill on disconnect | Yes — keep the match playable | No — the match is rating-bearing and must not change shape mid-way |
|
||||||
|
| Bots filling empty slots | Acceptable (`--fill-bots` exists) | Never |
|
||||||
|
| Abandon penalty | Light (short queue cooldown) | Real (rating loss, escalating cooldown) |
|
||||||
|
| Arena selection | Full rotation | Restricted set, so a variant nobody has practised can't decide a ladder match |
|
||||||
|
| Party / premade | Unrestricted | Constrained by size and rating spread |
|
||||||
|
|
||||||
|
Note the arena constraint interacts with an existing rule: elevated-goal
|
||||||
|
variants are Free-Play-only until a checkpoint trained on
|
||||||
|
`training_elevated.tscn` is promoted (`arena_registry.gd`). Ranked's arena
|
||||||
|
set should be drawn from `"random": true` arenas only.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Rating algorithm.** Glicko-2 is the default recommendation over plain
|
||||||
|
Elo: it models rating *uncertainty*, which matters enormously for a small
|
||||||
|
launch population where most players have few games. Not yet decided.
|
||||||
|
- **Team rating from individual ratings.** How a 3v3 match's outcome
|
||||||
|
distributes across six players is a separate design problem from the
|
||||||
|
rating system itself.
|
||||||
|
- **Server cost.** Allocated servers cost real money per match, unlike
|
||||||
|
community servers that players host themselves. `README.md`'s original
|
||||||
|
note about a subscription to fund servers is suddenly load-bearing again.
|
||||||
|
Population size and match length set the bill; this needs a number before
|
||||||
|
launch, not after.
|
||||||
|
- **Region / ping policy.** How much rating tolerance to trade for latency,
|
||||||
|
and whether cross-region is ever allowed at low population.
|
||||||
|
- **Placement matches** and whether ranked has a soft reset per season.
|
||||||
|
- **Backend language and hosting.** Not chosen. It does *not* have to be C#
|
||||||
|
despite the original README framing — that framing was aspirational and
|
||||||
|
predates every real decision in this project.
|
||||||
|
|
||||||
|
## Explicitly out of scope
|
||||||
|
|
||||||
|
Tournaments, in-game leaderboards beyond a personal rank display,
|
||||||
|
cross-platform play with non-Steam identity providers, and spectator/observer
|
||||||
|
tooling for ranked matches. None are precluded by this design; none are
|
||||||
|
launch scope.
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# 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-todo.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-todo.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-todo.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-todo.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 `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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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" in `README.md`'s early framing was never built. A backend
|
||||||
|
service *is* now planned for matchmaking (see below), but nothing has
|
||||||
|
chosen C# for it — that framing predates every real decision here.
|
||||||
|
- **No HTTP/WebSocket/gRPC layer** for multiplayer — ENet/Steam SDR over UDP
|
||||||
|
only, via Godot's own `MultiplayerAPI`. Matchmaking will add the project's
|
||||||
|
first non-UDP network path, for backend traffic only; the simulation stays
|
||||||
|
on ENet/SDR.
|
||||||
|
- **No ONNX or other ML runtime in the shipped game** — see "AI opponents"
|
||||||
|
above.
|
||||||
|
|
||||||
|
## Planned, not yet built
|
||||||
|
|
||||||
|
- **A matchmaking backend service** — Steam auth ticket validation, casual
|
||||||
|
and ranked queues, a rating store, and per-match dedicated-server
|
||||||
|
allocation. Language and hosting are undecided. This is a 1.0 launch
|
||||||
|
blocker and the single largest departure from "one Godot project, no
|
||||||
|
backend". See [`MATCHMAKING.md`](MATCHMAKING.md).
|
||||||
@@ -28,6 +28,29 @@ implementation evidence, and completed work stay in
|
|||||||
- [ ] Add Steam auth tickets, verified Steam identity in the roster, and a
|
- [ ] Add Steam auth tickets, verified Steam identity in the roster, and a
|
||||||
persistent ban list. This fixes the slot-reclaim security issue below.
|
persistent ban list. This fixes the slot-reclaim security issue below.
|
||||||
|
|
||||||
|
## Phase 8 — casual and ranked matchmaking (1.0 launch blocker)
|
||||||
|
|
||||||
|
Design and reasoning: [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). This is a
|
||||||
|
different server model from the community-server one that exists today —
|
||||||
|
players queue, a matchmaker groups them, and a server is allocated per match.
|
||||||
|
Phase 7's Steam auth tickets are a hard prerequisite: a rating attached to a
|
||||||
|
spoofable identity is worse than no rating.
|
||||||
|
|
||||||
|
- [ ] Decide the rating algorithm (Glicko-2 recommended over Elo for a small
|
||||||
|
launch population) and how a team result distributes across individuals.
|
||||||
|
- [ ] Choose the backend language and hosting, and cost out allocated servers
|
||||||
|
per match at expected population.
|
||||||
|
- [ ] Stand up the backend: Steam auth ticket validation via the Steamworks
|
||||||
|
Web API, queue, rating store, server allocator.
|
||||||
|
- [ ] Add an assigned-roster server mode so only matched SteamIDs may claim a
|
||||||
|
slot, replacing the first-come model.
|
||||||
|
- [ ] Add server-authoritative match result reporting to the backend over a
|
||||||
|
channel a client cannot forge.
|
||||||
|
- [ ] Client queue UI: playlist select, estimated wait, accept/decline,
|
||||||
|
connect-on-assignment, post-match rating delta.
|
||||||
|
- [ ] Casual and ranked playlist rulesets (backfill, bots, abandon penalties,
|
||||||
|
arena restriction — see the comparison table in the design doc).
|
||||||
|
|
||||||
## Known issues to resolve before public hosting
|
## Known issues to resolve before public hosting
|
||||||
|
|
||||||
- [ ] Slot reclaim is currently keyed by display name, so someone can take a
|
- [ ] Slot reclaim is currently keyed by display name, so someone can take a
|
||||||
|
|||||||
Reference in New Issue
Block a user