docs(multiplayer): split spec into MULTIPLAYER_SPEC.md, trim task doc to outstanding work

multiplayer-next.md was a 1662-line mix of standing architecture spec
and task-completion tracking, most of which was dense per-task DONE
evidence for finished Phases 0-6. Split it:

- MULTIPLAYER_SPEC.md (new): the locked architecture decisions, wire
  format, server-side input handling, prediction/reconciliation,
  latency/frame-rate budget, and match lifecycle state machine -
  standing design reference, not task-tracked.
- multiplayer-next.md (trimmed 1662 -> ~370 lines): only outstanding
  work remains - §0 status, §7 Phase 7/8 task tables condensed to
  "what's left" per task, §8-11 reference material (refactoring notes,
  gotchas, testing, flagged items). Phases 0-6 collapsed to a pointer
  at git history instead of ~500 lines of DONE evidence.

Also:
- Repointed every `multiplayer-next.md §N` code comment (N 1-6) across
  Game/scripts, Game/tools and Game/tests to MULTIPLAYER_SPEC.md, since
  those sections moved. Task-number references (`task N.N`, §7-11)
  correctly still point at multiplayer-next.md.
- Updated CLAUDE.md's doc index and docs/TECH_STACK.md's spec-section
  citations to match.
- TODO.md: added a "what's left to actually finish multiplayer
  (human-actionable)" checklist pulled from multiplayer-next.md §0 and
  docs/MATCHMAKING.md - things that need a person (hardware, a design
  decision, a Steam App ID, hands on a controller), not more agent code.
This commit is contained in:
Josh Creek
2026-09-04 22:43:13 +01:00
parent de263f30e8
commit b43ad207c1
21 changed files with 848 additions and 1543 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
class_name InputJitterBuffer
extends RefCounted
# Per-player server-side input state (multiplayer-next.md §3, task 3.2).
# Per-player server-side input state (MULTIPLAYER_SPEC.md §3; multiplayer-next.md task 3.2).
# Deliberately a standalone RefCounted with no scene/RPC dependency — same
# reason net_codec.gd and net_interpolator.gd are pure classes — so task
# 3.5's unit tests can drive it with scripted arrival traces with no live
@@ -18,7 +18,7 @@ extends RefCounted
# class's, since only the caller knows the current server tick.
const RING_SIZE := 32
# 500ms at 60Hz (multiplayer-next.md §3.2's own numbers) — a duration, not a
# 500ms at 60Hz (MULTIPLAYER_SPEC.md §3.2's own numbers) — a duration, not a
# tick-rate-derived constant, so left as a literal rather than pulling in
# SimConstants for one number.
const STARVE_ZERO_TICKS := 30
+1 -1
View File
@@ -1,7 +1,7 @@
class_name InputLeadController
extends RefCounted
# Client-owned input_lead control loop (multiplayer-next.md §3.3, task 3.3).
# Client-owned input_lead control loop (MULTIPLAYER_SPEC.md §3.3; multiplayer-next.md task 3.3).
# Standalone RefCounted, same reason as input_jitter_buffer.gd — scene-free
# so it's directly unit-testable against scripted depth traces.
#
+1 -1
View File
@@ -3,7 +3,7 @@ extends RefCounted
const NetBodyState = preload("res://scripts/net_body_state.gd")
# Client-owned local-ship prediction history (multiplayer-next.md §4.3).
# Client-owned local-ship prediction history (MULTIPLAYER_SPEC.md §4.3).
# This is deliberately independent of NetworkedMatch and the scene tree so
# sequence/ring behaviour can be tested from scripted traces. Each entry is
# tagged with its full sequence number: an old value in a wrapped slot is
+1 -1
View File
@@ -1,7 +1,7 @@
extends Node
# Autoload (project.godot [autoload] MatchNet). Handshake + roster layer on
# top of NetworkManager's raw transport (§2.5, §1.3 of multiplayer-next.md).
# top of NetworkManager's raw transport (§2.5, §1.3 of MULTIPLAYER_SPEC.md).
# hello/welcome, strict protocol_version and physics_ticks_per_second
# gating, player_joined/player_left, and — since lobby.tscn (task 1.5) needs
# somewhere durable to keep it across the lobby→match scene transition —
+1 -1
View File
@@ -41,7 +41,7 @@ signal match_bootstrap_received(state: int, at_tick: int, score: Dictionary, end
# §6.3 task 5.8: a spectator has been given a vacated slot at a kickoff.
signal slot_assigned_received(peer_id: int, slot_index: int)
# Input validation (multiplayer-next.md §3.1 steps 2-3, task 3.4). Deliberately
# Input validation (MULTIPLAYER_SPEC.md §3.1 steps 2-3; multiplayer-next.md task 3.4). Deliberately
# lives here rather than in NetworkedMatch: framing/rate abuse is a protocol-
# level concern independent of any particular match's roster/slot state, and
# this autoload already owns the RPC that receives the raw bytes.
+1 -1
View File
@@ -1,6 +1,6 @@
class_name MatchState
# Match lifecycle states (multiplayer-next.md §6.1, task 5.1).
# Match lifecycle states (MULTIPLAYER_SPEC.md §6.1; multiplayer-next.md task 5.1).
#
# Pure data + a transition table, deliberately with no scene, RPC or
# NetworkedMatch dependency — same reason net_codec.gd and
+1 -1
View File
@@ -1,6 +1,6 @@
extends RefCounted
# Plain data holder for one body's snapshot state (§2.4 of multiplayer-next.md).
# Plain data holder for one body's snapshot state (§2.4 of MULTIPLAYER_SPEC.md).
# Deliberately not Ship/Ball themselves, and deliberately not a scene-tree
# node — NetCodec's pack/unpack must stay callable from pure-function tests
# with no live scene. Phase 2's snapshot writer fills one of these per body
+2 -2
View File
@@ -1,7 +1,7 @@
class_name NetCodec
# Wire-format constants, quantisers, and pack/unpack for the two hot-path
# packets (§2 of multiplayer-next.md). Pure functions only — no networking,
# packets (§2 of MULTIPLAYER_SPEC.md). Pure functions only — no networking,
# no autoload state — so they're testable head-on by tests/test_runner.tscn
# without a live connection.
#
@@ -48,7 +48,7 @@ const BODY_FLAG_STALLED := 1 << 5
const BODY_FLAG_QUAT_W_SIGN := 1 << 6
# --- Quantisation ranges (§2.4 — derived from arena/gameplay constants, not
# restated prose; see multiplayer-next.md for the ArenaBoundary/Ship/Ball
# restated prose; see MULTIPLAYER_SPEC.md for the ArenaBoundary/Ship/Ball
# constants these are sized against) ---
const POS_RANGE := 64.0 # metres, ±
const VEL_RANGE := 64.0 # m/s, ±
+1 -1
View File
@@ -3,7 +3,7 @@ extends RefCounted
# Buffers recent snapshot samples for ONE remote body and produces
# interpolated states at any requested (possibly fractional) server tick —
# used twice per body (multiplayer-next.md §4.1/§4.6, "dual-time remote
# used twice per body (MULTIPLAYER_SPEC.md §4.1/§4.6, "dual-time remote
# entities"): once at the present-time estimate for the collider, once
# further back at present-minus-INTERP_DELAY for $Visual.
#
+1 -1
View File
@@ -1,6 +1,6 @@
extends RefCounted
# Local-ship reconciliation policy (multiplayer-next.md §4.4). Kept out of
# Local-ship reconciliation policy (MULTIPLAYER_SPEC.md §4.4). Kept out of
# NetworkedMatch so the decision table is pure-testable; the imperative half
# only writes Ship's existing Jolt-safe queued correction hooks.
+1 -1
View File
@@ -3,7 +3,7 @@ extends Node
# Autoload (project.godot [autoload] NetworkManager). Owns transport-neutral
# hosting, joining, shutdown, and connection-state signals. Lives
# at a fixed autoload path so RPC NodePaths never depend on which scene is
# loaded (§1.3 of multiplayer-next.md's derived decisions).
# loaded (§1.3 of MULTIPLAYER_SPEC.md's derived decisions).
#
# server_relay = false is set the moment a peer exists: the default `true`
# lets any client rpc() any other client *through the server*, which this
+1 -1
View File
@@ -145,7 +145,7 @@ func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3,
_has_pending_teleport = true
# --- Netcode correction hooks (Phase 4; see multiplayer-next.md §4.4) ---
# --- Netcode correction hooks (Phase 4; see MULTIPLAYER_SPEC.md §4.4) ---
# Both stay zero until Phase 4 wires a reconciliation pass in, so the guarded
# hook in _integrate_forces below is a no-op today.
# Velocity delta from a soft correction, consumed once then cleared —
+1 -1
View File
@@ -4,7 +4,7 @@ class_name SimConstants
# constant derived from "60 Hz" (Ship._tick_scaled's decay reference,
# reaction_ticks' export range, TrainingMode.TICKS_PER_SIM_SECOND) reads this
# instead of restating the literal, so changing it changes every derived
# constant coherently — see multiplayer-next.md §5.6 on why a future 120 Hz
# constant coherently — see MULTIPLAYER_SPEC.md §5.6 on why a future 120 Hz
# simulation needs to be a config change plus a retrain, not a protocol
# rewrite hunting down bare 60s.
#
+3 -3
View File
@@ -24,7 +24,7 @@ extends Node
# independently of stretch mode, since it scales the 3D viewport's own internal
# resolution before this blit rather than the window itself. Task 0.15b also
# found an unexplained ~6% non-uniform width scaling on this project's one
# tested (Mac/Retina) machine — see multiplayer-next.md §5.5.1 — which needs
# tested (Mac/Retina) machine — see MULTIPLAYER_SPEC.md §5.5.1 — which needs
# understanding before stretch mode is touched, not blindly carrying into a
# resolution-dependent change.
#
@@ -49,7 +49,7 @@ const SETTINGS_PATH := "user://settings.cfg"
# preset -> bundle applied to the individual fields below. CUSTOM has no
# bundle: selecting it just stops future preset changes from overwriting
# whatever the individual fields currently hold. Task 0.15b's measured
# per-effect costs (multiplayer-next.md §5.5.1) were too noisy to rank these
# per-effect costs (MULTIPLAYER_SPEC.md §5.5.1) were too noisy to rank these
# against each other, so each rung is "meaningfully fewer full-screen passes
# than the one above it" rather than a precisely tuned ladder.
const PRESET_BUNDLES := {
@@ -252,7 +252,7 @@ func apply_fps_cap() -> void:
# Called once by each arena's _ready() (and again on settings_changed, so an
# already-loaded arena updates live) to fold the user's glow/brightness
# preference into that arena's own baked Environment tuning, and to gate the
# preset-controlled full-screen passes (§5.5 of multiplayer-next.md).
# preset-controlled full-screen passes (§5.5 of MULTIPLAYER_SPEC.md).
func apply_to_environment(env: Environment) -> void:
if env == null:
return