CLAUDE.md and README.md described the pre-multiplayer state (local-only
MVP, planned C# backend) even though server-authoritative multiplayer,
the dedicated server, Docker/CI verification, and Steam transport have
since shipped (Phases 1-6). Update both to reflect reality and add a
docs index in CLAUDE.md pointing at multiplayer-next.md as the current
checklist.
- Add docs/TECH_STACK.md, linked from README, explaining the stack and
why it's a single GDScript project with no separate backend.
- Add one TODO item for the video settings menu (missing presets/vsync/
resolution scaling), blocked on the same profiling gate as the
multiplayer 0.17 tasks.
- Pick up editor-generated .gd.uid sidecars and minor project.godot
formatting noise from opening the project in Godot 4.7.
Godot's ConfigFile writer does not round-trip comments in project.godot. An
observed rewrite deleted both `;` blocks outright and spliced the three-line
`#` block above run/main_scene.dedicated_server onto the setting's own line,
leaving it commented out — which would send dedicated builds to the
interactive main menu instead of server_boot.tscn, with nothing failing until
someone noticed a server process rendering a menu.
Move the explanations into the code that owns the settings (server_boot.gd for
the dedicated-server override, video_settings.gd for stretch mode and vsync)
so they cannot be destroyed by a rewrite, and leave project.godot holding only
assignments plus Godot's own regenerated header.
Add tests/cases/test_project_settings.gd as the backstop: the feature-override
assertions read project.godot as text and reject a line that has been folded
into a comment, since ProjectSettings resolves `key.<feature>` overrides at
load time and never exposes the suffixed key. Verified by reproducing the
exact corruption, which fails the test, and it also covers the Jolt physics
engine, the required autoloads, and that no test-hook autoload is ever shipped
registered.
6.1: "Linux Dedicated Server" preset (dedicated_server=true,
custom_features="dedicated_server") mirroring the existing training
preset, plus run/main_scene.dedicated_server so the server binary reaches
its own entry point with no flag. Builds: an 85MB Linux x86_64 binary,
gitignored like the training one.
6.3: scripts/server_config.gd declares every server flag once - name,
type, default, section, help - and one parser turns that into parsing,
type checking, range validation, config-file backing and --help. The
flags had grown to ~30 across server_boot.gd and networked_match.gd, each
parsed inline with begins_with, none documented, and an unrecognised flag
was SILENTLY IGNORED: --max-clientss=8 ran a server on the default cap
and said nothing. Unknown flags, missing values, wrong types, duplicates
and out-of-range values are now hard errors, reported all at once.
Precedence is command line > config file > default. server_boot.gd parses
strictly because it owns the whole command line; networked_match.gd reads
the same declaration leniently because it is one consumer of an argv the
smoke harnesses also fill with --role= and --drive-seconds=. Nothing is
lost - every server flag is declared, so the strict pass already caught
any typo before the match scene re-reads its own.
13 unit tests covering the precedence order, the typo rejection that
motivated this, --no-<bool> not double-listing in --help, and --help
documenting every flag asserted against the declaration rather than a
hand-kept list. Verified end to end: --help prints, a typo'd flag refuses
to start, and the plain/replay-log/late-joiner smoke scenarios still pass.
New NetSim autoload: seeded, CLI-driven (--net-sim-latency/-jitter/-loss/-dup)
latency/jitter/loss/duplicate decorator, a true no-op passthrough unless a
flag is set. Wraps MatchSim.send_input/send_snapshot per the design doc's
scope, plus NetworkManager's ping/pong so the already-tested RTT/clock
measurement becomes the acceptance signal for "raises observed RTT" without
waiting on Phase 3's per-peer snapshot echo.
Two real bugs found while building and verifying this against Phase 2's own
milestone gate (a real match under --net-sim-latency 80 --net-sim-jitter
20, not just LAN): a timestamp captured inside a delayed RPC closure
silently ate that side's own added delay out of the round-trip
measurement instead of adding to it; and a delayed send whose target
disconnected (or whose own process had already shut down) during the hold
threw RPC errors, since the existing get_peers() filtering only checked
validity at schedule time. Fixed by capturing timestamps before handing
off to NetSim, and by having NetSim re-validate the target at fire time.
Phase 2's milestone gate now passes for real: a full 1v1 under simulated
80ms latency / 20ms jitter still shows clean server-authoritative
movement and zero RPC errors. Full Phase 1 + Phase 2 regression suite
re-verified clean with NetSim present but inactive.
Implements tasks 2.1-2.7: NetworkedMatch spawns a deterministic slot
layout from the lobby roster, the server drives each connected peer's
ship via RLShipController fed by decoded client input and broadcasts
60Hz snapshots, and the client renders everything (including its own
ship) from a per-body NetInterpolator with no local prediction yet.
Dual-time remote entities split collider updates (present-time, for
correct contacts) from $Visual updates (interp-delayed, for smoothness).
Camera/HUD wiring and remote engine-flame VFX fell out of the existing
Ship API for free once snapshots were flowing.
Three real bugs found and fixed while getting a two-process test
green: an RPC method named _input collided with Node's built-in
_input virtual and broke the whole MatchSim autoload from loading;
networked_match.gd never called NetworkManager.poll(), so nothing
sent via RPC in this scene reached the wire despite Phase 1's manual
polling being wired up everywhere else; and a match_config
request/response fallback (added to close a startup race) could
double-deliver once polling was fixed, requiring an idempotency guard.
Verified with tests/networked_match_smoke: a real headless two-process
host+client run shows the client rendering 31m of server-authoritative
movement from a held forward-thrust input, with thrust_z=1.0 confirmed
on the interpolated snapshot mid-drive and camera/HUD both wired.
Full Phase 1 regression suite re-run clean alongside it.
Task 2.8 (net_sim.gd latency/jitter/loss decorator) is not yet done;
Phase 2's own gate needs it before it's fully met.
Lands tasks 1.0-1.8 of multiplayer-todo.md: the pure-function test runner,
net_codec (wire format quantizers/pack-unpack), NetworkManager (ENet
transport, manual polling, min-RTT clock sync), MatchNet (handshake,
protocol/tick-rate gating, roster with team+ready state), lobby.tscn (team
columns, switch team, ready toggle), server_boot.tscn (headless dedicated
server with structured logging and an overrun watchdog), and main_menu.gd's
Host/Join-by-IP UI (connecting overlay, cancel, bounded failure path).
Followed by an adversarial review (Opus subagent) that found and fixed two
real bugs - an unvalidated player_name broadcast that let one client's
oversized name head-of-line-block the reliable channel for everyone, and a
server-side roster leak across a host/re-host cycle - plus three gaps in
the test suite itself where a claim of "verified" wasn't actually backed
by what the test checked. All five two-process smoke tests plus the
pure-function suite are green with the strengthened assertions in place.
Lands the non-networked Phase 0 tasks from multiplayer-todo.md (ship/camera/
arena refactors, sim constants, background FPS handling) plus a first pass
at exposing graphics/performance settings (presets, resolution scaling,
vsync, FPS cap, perf overlay) and a GPU profiling harness for the
real-hardware follow-up in task 0.15b.
Adds a [layer_names] section to project.godot (Ships/Ball/Arena/
GoalSensor) and sets collision_layer/collision_mask on the 4 physics
body roots (Ship, Ball, Goal Area3D, ArenaBoundary StaticBody3D),
which previously all sat on the default layer 1 / mask 1 so every
body broadphase-tested against every other.
Ships collide with ships/ball/arena but no longer test against the
goal sensor; the goal's mask (Ball only) is the physics-level fix for
what goal.gd's is_in_group("ball") check was doing defensively in
code (left in place, now a no-op guard). ArenaBoundary's runtime-
generated CollisionShape3D children inherit layer/mask from the
StaticBody3D root automatically.
Verified live via godot-mcp under Jolt Physics: ship-vs-ship and
ship-vs-ball collisions still transfer momentum, the ball still
bounces off arena walls, a ball entering a goal still fires
goal_scored (score updates / HUD reset), and a ship teleported into a
goal recess produces zero overlapping bodies on the goal Area3D (was
reachable before, physics-level fix confirmed, not just the code
guard). Headless smoke test (free_play.tscn) is clean.
SDFGI ran with default cascades in a volume that was almost entirely
transparent, so its low-resolution probes had little solid geometry to
capture and contributed blotching across large flat surfaces. With the
boundary now an opaque deck plus an unshaded additive field, it earns
nothing, and the existing ReflectionProbe and ambient carry the lighting.
Each arena also gains a shadowless fill light opposite its key light so the
deck is not lit from a single direction, and passes its own tint through to
the boundary's containment field so the three arenas read differently.
use_debanding was absent. Glow runs at hdr_scale 2.0 with adjustment_contrast
above 1.0, which amplifies 8-bit quantisation on exactly the kind of smooth
gradients the new shell is made of.
The remaining project.godot churn is Godot's own key reordering on save.
Enable glow/bloom, color adjustments, and MSAA+FXAA across all three
arenas so emissive ship/station accents actually bleed light and edges
read cleanly. Expose the player-facing knobs (anti-aliasing mode, glow
intensity, brightness) through a new Settings screen off the main menu,
persisted via a VideoSettings autoload that scales each arena's own
tuned Environment values on load rather than overwriting them.
Introduce arena_02 (Nebula) and arena_03 (Asteroid Field) alongside
arena_01, listed in a new ArenaRegistry (scripts/arena_registry.gd) as
the single source of truth for available arenas. Free Play lets the
player pick an arena from the main menu; Match/Spectate each pick one
at random per session; Training keeps its own fixed arena_01.
Nebula gets an RL-style visual treatment: a near-invisible glass
boundary material shared by all arenas, a baked equirect nebula sky
texture, a drifting-dust particle system, and a Blender-modeled
station/debris/planet decoration set. GameMode gains a
_get_arena_scene_path() hook so modes can instantiate their arena in
code instead of hardcoding it in the scene.