Files
CosmicClash/Game/scripts/input_jitter_buffer.gd
Josh Creek b43ad207c1 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.
2026-09-04 22:43:13 +01:00

179 lines
8.3 KiB
GDScript

class_name InputJitterBuffer
extends RefCounted
# 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
# match. NetworkedMatch owns one instance per connected slot and is the only
# thing that talks to the network layer; this class only knows about
# sequence numbers and ShipActions.
#
# Ring is fixed-size and slot-tagged (§3.1 step 5's "a client can never make
# the server allocate"): ingest() writes seq % RING_SIZE regardless of how
# large or malicious seq is, and consume() only ever trusts a slot whose
# stored seq exactly matches the one it expects — a stale or wrapped-around
# entry is indistinguishable from an empty one. Range/rate validation of seq
# against the current server tick is the CALLER's job (task 3.4), not this
# class's, since only the caller knows the current server tick.
const RING_SIZE := 32
# 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
var last_applied_seq := -1 # -1: consume() has never been called yet
var last_action := ShipAction.new()
var starved_ticks := 0
var stalled := false
var _ring_action: Array = []
var _ring_seq: PackedInt32Array = PackedInt32Array()
# True once ingest() has ever been called for real. Consumption is a no-op
# (no starvation counted, no advancement) until then — see ingest()'s own
# comment for why an un-seeded buffer would otherwise never converge with
# what the client is actually sending.
var _seeded := false
# Highest seq ever seen by ingest(), regardless of whether it's still in the
# ring — consume()'s only way to tell "the data is gone because the ring
# overflowed" apart from "the data just hasn't arrived yet". See consume()'s
# own comment for why this exists: an adversarial review found that without
# it, a backlog bigger than RING_SIZE (a host stall, or persistent client/
# server clock drift) permanently zeroed a connected player's input for the
# rest of the match.
#
# Deliberately public (no underscore), same as last_applied_seq: the
# networked_match.gd caller's seq-range guard (§3.1 step 4) must bound
# against THIS, not against last_applied_seq. A second adversarial review
# found that bounding against last_applied_seq caps every accepted seq at
# last_applied_seq + RING_SIZE, which in turn caps this field at the same
# ceiling — making the resync condition below (which needs this field to
# reach expected + RING_SIZE) arithmetically unreachable on the only call
# path that exists in production. The two fixes looked independent but
# shared a variable and silently cancelled each other out. highest_ingested
# tracks the client's own send epoch instead, which the guard can safely
# let run ahead of a lagging consumer.
var highest_ingested_seq := -1
func _init() -> void:
_ring_action.resize(RING_SIZE)
_ring_seq.resize(RING_SIZE)
for i in RING_SIZE:
_ring_seq[i] = -1
# newest_seq/actions match NetCodec.unpack_input's own "seq"/"actions"
# fields directly: actions[i] is the action for sequence (newest_seq - i),
# newest-first. Already-consumed or stale entries are silently discarded
# (§3.1 step 5) — this is what makes redundant re-delivery of an already-
# applied tick harmless.
func ingest(newest_seq: int, actions: Array) -> void:
if not _seeded:
# The server starts calling consume() every tick the instant this
# slot exists — well before this player's first packet has had time
# to arrive (connection handshake, arena/ship spawn, first
# _physics_process tick on the client all take real time first). An
# un-seeded last_applied_seq of -1 would have consume() "expecting"
# sequence 0, 1, 2, ... via pure starvation the whole time, racing
# arbitrarily far ahead of whatever the client's own from-1
# numbering has actually reached by the time real packets show up —
# and since both sides only ever advance monotonically with no
# resync mechanism, that gap would never close. Seed to align
# "expected" with reality the moment real data first exists.
last_applied_seq = newest_seq - actions.size()
_seeded = true
if newest_seq > highest_ingested_seq:
highest_ingested_seq = newest_seq
for i in actions.size():
var seq: int = newest_seq - i
if seq <= last_applied_seq:
continue
var idx := seq % RING_SIZE
_ring_seq[idx] = seq
_ring_action[idx] = actions[i]
# Contiguous run of not-yet-applied entries starting right after
# last_applied_seq — reported as input_buffer_depth in every snapshot
# (§3.3) and consumed client-side by the input_lead control loop (task 3.3).
func depth() -> int:
if not _seeded or last_applied_seq < 0:
return 0
var d := 0
var seq := last_applied_seq + 1
while d < RING_SIZE and _ring_seq[seq % RING_SIZE] == seq:
d += 1
seq += 1
return d
# Called once per server physics tick, before the step (§3.2). A no-op
# (returns the zero-initialized last_action, no starvation counted) until
# this player's first real packet has ever arrived — see ingest()'s comment.
func consume() -> ShipAction:
if not _seeded:
return last_action
var expected := last_applied_seq + 1
var idx := expected % RING_SIZE
# Ring-overflow resync. A fixed-size ring can only ever hold RING_SIZE
# ticks of not-yet-consumed data at once — if the caller has fallen
# further behind the newest data actually arriving than that (a host
# stall, or persistent client/server clock drift), every tick between
# "expected" and "highest_ingested_seq - RING_SIZE" has already been
# irrecoverably overwritten by more recent arrivals landing on the same
# ring slots. Waiting for it tick-by-tick would starve — and, past
# STARVE_ZERO_TICKS, zero this player's ship — for the ENTIRE gap even
# though fresh, real input already exists in the ring right now. An
# adversarial review found and reproduced this exact failure (a ~0.7s
# host freeze permanently zeroed a connected player's input for the
# rest of the match, with no self-recovery). Skip the unrecoverable
# span and resync directly to what the ring can still actually provide.
if highest_ingested_seq - expected >= RING_SIZE:
last_applied_seq = highest_ingested_seq - RING_SIZE
expected = last_applied_seq + 1
idx = expected % RING_SIZE
if _ring_seq[idx] == expected:
last_action = _ring_action[idx]
starved_ticks = 0
stalled = false
last_applied_seq = expected
return last_action
# Repeat-last, not zero: inputs are heavily autocorrelated at 60Hz,
# and the client already predicted with the real input either way,
# so repeating minimises expected divergence (§3.2). Only zero after
# a sustained stall, so a disconnecting player's ship doesn't fly
# into a wall at full throttle forever.
starved_ticks += 1
if starved_ticks > STARVE_ZERO_TICKS:
last_action = ShipAction.new()
stalled = true
# Only GIVE UP on `expected` when strictly newer data has actually
# arrived, which proves it was lost or reordered rather than merely late.
#
# Advancing unconditionally (what this did originally) is catastrophic
# rather than merely lossy, because ingest() discards anything
# `seq <= last_applied_seq`. One starve on a sequence the client has not
# even sent yet leaves the server permanently one ahead of arrivals:
# both sides then advance one per tick, the gap never closes, and every
# honest packet is discarded on arrival for the rest of the match. An
# adversarial review reproduced exactly that on a clean LAN — the client's
# own input_lead RELEASE (delta == 0, which deliberately issues no new
# sequence for one tick) is sufficient to trigger it, so it fired roughly
# every 6.5s of ordinary play, blacking out input for 30 ticks until the
# lead controller's debounce allowed a +3 attack to jump the client clear.
#
# Holding cannot deadlock: if the client genuinely goes silent,
# highest_ingested_seq stops moving, starved_ticks still climbs, and the
# STARVE_ZERO_TICKS zeroing plus `stalled` above still fire on schedule.
# If it falls far behind instead, the ring-overflow resync above still
# jumps the cursor forward. Both escape paths are unchanged.
if highest_ingested_seq > expected:
last_applied_seq = expected
return last_action