New InputLeadController (scripts/input_lead_controller.gd, standalone and
unit-tested like input_jitter_buffer.gd): fast attack (+3 immediately,
debounced to once per 30 ticks) on any server-reported starve, slow
release (-1 per 60 ticks, gated behind a one-time 2s clean-surplus bar)
otherwise, clamped [1, 12]. Deliberately the only thing that adapts
buffer depth - the server (InputJitterBuffer) stays a pure reporter, per
§3.3's explicit warning that multiple control loops acting on one plant
(buffer occupancy) oscillate and present as unattributable sticky
controls.
Wired into the client's per-tick input send: a lead change is realized as
extra distance between the client's outgoing sequence numbers and what
the server has consumed - an attack skips extra sequence numbers, a
release duplicates the current one (sent again, unincremented). The
server's ring buffer needs no special handling for either: a skipped seq
is an ordinary drop, a duplicated one is a same-seq resend already
discarded by the existing "already consumed" check.
Verified with real two-process runs: on a clean LAN, one early attack
(a momentary hiccup during connection setup) recovers via two releases
within the test's own ~4s window, settling back near minimum. Under
sustained 30% simulated loss, lead climbs to 7 via repeated attacks and
never releases while genuine loss continues - confirming the debounce,
attack, and release gates all fire on real conditions, not just in
isolated unit tests. Full regression suite, including the net-sim-latency
milestone gate, re-run clean.
Client now sends the last 4 ticks' actions per packet (newest-first,
already-supported by net_codec's wire format from Phase 1) instead of a
single action with no redundancy. Server gains a real per-slot ring
buffer (new InputJitterBuffer class, scripts/input_jitter_buffer.gd) that
consumes exactly one sequence number per physics tick: repeats the last
action on a starve, zeroes only after a sustained 500ms stall, and
reports real input_buffer_depth/last_input_seq/echo_client_send_ms in
every snapshot instead of the hardcoded zeros Phase 2 shipped with.
InputJitterBuffer is a standalone, scene-free RefCounted (same pattern as
net_codec.gd/net_interpolator.gd) specifically so it's unit-testable
against scripted arrival traces (tests/cases/test_input_jitter_buffer.gd):
sequential consumption, redundancy surviving a 3-packet burst loss (3.1's
own acceptance criterion), starvation repeat-then-zero timing, stale/
reordered packet handling, buffered-depth reporting, and ring-wraparound
slot-tagging safety.
One real bug found wiring this into a live match: the server's ring
buffer started counting its own "expected sequence" from 0 the instant a
player's slot was created - well before that player's first real packet
could possibly have arrived (connection handshake, arena/ship spawn all
take real time first). Since both sides only ever advance monotonically
with no resync mechanism, that gap between the server's arbitrary local
counter and the client's actual from-1 sequence numbers never closed,
so the ship simply never received the client's input (0m movement in a
two-process test). Fixed by seeding the buffer's expected-sequence
counter from the client's own numbering on first real ingest, rather
than assuming a shared from-zero baseline.
Verified with real two-process runs: clean baseline movement restored,
zero starvation observed under 25% random simulated input loss (well
above what redundancy-4 needs to fully absorb), and correct starve-then-
stall behaviour confirmed under 100% loss as a sanity check that the
mechanism isn't a silent no-op. Full regression suite, including the
net-sim-latency milestone gate, re-run clean.
An Opus subagent's adversarial review of Phase 2 found real bugs the
smoke tests couldn't catch, since constant-velocity dead reckoning still
moves a ship far enough to pass a "moved > 1.0" check:
- The interpolator never actually interpolated. NetInterpolator.to_tick()
assumes physics_frame * TICK_MS == Time.get_ticks_msec() on the server,
which is off by a steady ~45-55ms in practice (real startup work before
the first physics step, widened by any dropped tick). Every sample_at()
call took the extrapolation branch, 100% of the time, defeating the
interpolation buffer entirely. Fixed with a shared, min-filtered rolling
bias estimate in networked_match.gd, applied before every to_tick() call.
- Goals caused a ~27m visual slide: _reset_gen was bumped before the
queued teleport actually landed, so the client's buffer-clear kept
exactly the stale in-goal sample and lerped a slide to the next, real
one. Fixed by tracking the tick the goal was detected on and only
bumping the generation once strictly later ticks confirm the teleport
has landed - a naive "next _physics_process" boolean flag doesn't
work, since a goal Area's body_entered fires before that same tick's
_physics_process runs, not on the next one.
- _local_input_sampler (a Node, never added to the tree) was never freed
- this was the unexplained "3 resources still in use at exit" warning
on every Phase 2 test run.
- Ball angular velocity decoded 8x too small (rescale_avel was never
called); get_server_time_estimate_ms() was used before the clock had
synced; net_sim.gd's delayed-send timer stopped ticking while the tree
was paused and didn't check connection status before firing;
_broadcast_snapshot's ball index could silently break if a ship were
ever despawned; declared-but-unemitted HUD lifecycle signals showed a
permanently frozen timer widget.
Also confirmed, empirically, several things the review checked and found
fine: a hostile client sending malformed input cannot crash the server,
skipping GameMode's super() drops nothing load-bearing, deterministic
slot assignment is correct with 2 real simultaneous clients, and RPC
authority enforcement genuinely rejects a forging client.
All fixes verified with real two-process runs (including forcing an
actual goal and reading the server's own broadcast stream) and temporary
instrumentation, removed once each fix was confirmed. Full Phase 1 +
Phase 2 regression suite, including the net-sim-latency milestone gate,
re-run clean after every fix.
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.