Files
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

127 lines
5.1 KiB
GDScript

class_name NetInterpolator
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_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.
#
# server_tick (Engine.get_physics_frames() at send time) maps to an
# estimated server wall-clock time via TICK_HZ without any extra
# synchronization: both Engine.get_physics_frames() and Time.get_ticks_msec()
# count from the same process-start epoch, and physics has been running at
# a steady TICK_HZ the whole time, so tick_ms_of(tick) = tick * (1000/TICK_HZ)
# is a valid estimate of "what Time.get_ticks_msec() read on the server when
# it sent that tick." Callers convert a NetworkManager.get_server_time_estimate_ms()
# reading into the same tick-space with to_tick(ms) before calling sample_at().
const NetBodyState = preload("res://scripts/net_body_state.gd")
const SimConstants = preload("res://scripts/sim_constants.gd")
const MAX_SAMPLES := 16
# §4.6: "never extrapolate indefinitely — a stuck ship reads better than one
# flying through a wall."
const MAX_EXTRAPOLATION_MS := 150.0
const TICK_MS := 1000.0 / SimConstants.TICK_HZ
var _samples: Array[Dictionary] = [] # [{tick:int, state:NetBodyState}], oldest first
var reset_gen := -1 # -1: no sample yet, so the first real sample is never treated as a mid-flight reset
static func to_tick(server_time_ms: float) -> float:
return server_time_ms / TICK_MS
# Returns true if this sample's reset_gen differs from the last one seen —
# the caller's cue to hard-snap instead of interpolating across a
# server-authoritative teleport (kickoff, goal reset) rather than sliding
# across the arena. Clears buffered history on a reset so a stale
# pre-reset sample can never bracket a post-reset one.
func add_sample(server_tick: int, state: NetBodyState, sample_reset_gen: int) -> bool:
# Never let a stale unreliable snapshot rewrite the epoch. The previous
# ordering cleared samples on its reset byte before checking tick order,
# so a delayed pre-reset packet could alternately flip generations and
# repeatedly cancel an active local ball handoff.
if not _samples.is_empty() and server_tick <= _samples.back()["tick"]:
return false
var is_reset := reset_gen != -1 and sample_reset_gen != reset_gen
if is_reset:
_samples.clear()
reset_gen = sample_reset_gen
_samples.append({"tick": server_tick, "state": state})
if _samples.size() > MAX_SAMPLES:
_samples.pop_front()
return is_reset
func has_samples() -> bool:
return not _samples.is_empty()
func accepts_tick(server_tick: int) -> bool:
return _samples.is_empty() or server_tick > int(_samples.back()["tick"])
func latest() -> NetBodyState:
return _samples.back()["state"] if not _samples.is_empty() else null
# target_tick may be fractional (a point in time between two integer ticks).
func sample_at(target_tick: float) -> NetBodyState:
if _samples.is_empty():
return null
if _samples.size() == 1:
return _samples[0]["state"]
if target_tick <= _samples[0]["tick"]:
return _samples[0]["state"]
var newest: Dictionary = _samples.back()
if target_tick >= newest["tick"]:
return _extrapolate(newest, target_tick)
for i in range(_samples.size() - 1):
var a: Dictionary = _samples[i]
var b: Dictionary = _samples[i + 1]
if a["tick"] <= target_tick and target_tick <= b["tick"]:
var a_tick: float = a["tick"]
var b_tick: float = b["tick"]
var span := b_tick - a_tick
var t: float = (target_tick - a_tick) / span if span > 0.0 else 0.0
return _lerp_state(a["state"], b["state"], t)
return newest["state"]
func _lerp_state(a: NetBodyState, b: NetBodyState, t: float) -> NetBodyState:
var out := NetBodyState.new()
out.position = a.position.lerp(b.position, t)
out.rotation = a.rotation.slerp(b.rotation, t)
out.linear_velocity = a.linear_velocity.lerp(b.linear_velocity, t)
out.angular_velocity = a.angular_velocity.lerp(b.angular_velocity, t)
out.frozen = b.frozen
out.turbo = b.turbo
out.thrust_z = b.thrust_z
out.stalled = b.stalled
out.avel_range = b.avel_range
return out
func _extrapolate(newest: Dictionary, target_tick: float) -> NetBodyState:
var state: NetBodyState = newest["state"]
var ticks_ahead: float = target_tick - float(newest["tick"])
var ms_ahead := ticks_ahead * TICK_MS
var clamped_ms := clampf(ms_ahead, 0.0, MAX_EXTRAPOLATION_MS)
var out := NetBodyState.new()
out.position = state.position + state.linear_velocity * (clamped_ms / 1000.0)
var angular_speed := state.angular_velocity.length()
if angular_speed > 0.00001:
out.rotation = (Quaternion(state.angular_velocity / angular_speed, angular_speed * (clamped_ms / 1000.0)) * state.rotation).normalized()
else:
out.rotation = state.rotation
out.linear_velocity = state.linear_velocity
out.angular_velocity = state.angular_velocity
out.frozen = state.frozen
out.turbo = state.turbo
out.thrust_z = state.thrust_z
out.stalled = state.stalled
out.avel_range = state.avel_range
return out