mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): Phase 3 tasks 3.1/3.2/3.5 - input redundancy + server jitter buffer
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.
This commit is contained in:
@@ -27,6 +27,7 @@ signal score_changed(score: Dictionary)
|
||||
const NetCodec = preload("res://scripts/net_codec.gd")
|
||||
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
const NetInterpolator = preload("res://scripts/net_interpolator.gd")
|
||||
const InputJitterBuffer = preload("res://scripts/input_jitter_buffer.gd")
|
||||
const HUD_SCENE = preload("res://scenes/HUD.tscn")
|
||||
|
||||
# Minimum plausible interpolation delay even on a same-machine/LAN link —
|
||||
@@ -72,6 +73,8 @@ class SlotInfo:
|
||||
var spawn_index: int
|
||||
var ship: Ship
|
||||
var controller: RLShipController # server only
|
||||
var jitter_buffer := InputJitterBuffer.new() # server only (§3.2)
|
||||
var last_client_send_ms := 0 # server only: echoed back per-peer next snapshot (§2.4)
|
||||
var interpolator := NetInterpolator.new() # client only
|
||||
|
||||
|
||||
@@ -80,6 +83,11 @@ var _my_slot: SlotInfo = null # client only
|
||||
var _ball_interpolator := NetInterpolator.new() # client only
|
||||
var _local_input_sampler := PlayerShipController.new() # client only: reads local input each tick to forward; never added to a Ship, never in the tree — get_action() only touches the global Input singleton
|
||||
var _input_seq := 0 # client only
|
||||
# Redundancy (§3.1): newest-first, capped at NetCodec.MAX_REDUNDANCY, so a
|
||||
# 3-packet burst loss still recovers every tick's action via a later
|
||||
# packet's history. Client only.
|
||||
var _input_history: Array[ShipAction] = []
|
||||
var _last_received_snapshot_tick := 0 # client only: echoed back as ack_snapshot_tick
|
||||
var _reset_gen := 0 # server only: bumped on every kickoff/goal reset so the client hard-snaps instead of interpolating across the teleport
|
||||
# Server only. _on_goal_scored's reset_ball()/reset_ships() only QUEUE
|
||||
# teleports (task 0.15's queue_teleport — applied on each body's next
|
||||
@@ -196,11 +204,8 @@ func _start_server() -> void:
|
||||
func _on_input_received(peer_id: int, decoded: Dictionary) -> void:
|
||||
for slot in _slots:
|
||||
if slot.peer_id == peer_id:
|
||||
var actions: Array = decoded["actions"]
|
||||
# Newest-first; no redundancy handling yet (task 3.x) — just take
|
||||
# the newest one every time a packet arrives.
|
||||
if not actions.is_empty():
|
||||
slot.controller.action = actions[0]
|
||||
slot.jitter_buffer.ingest(decoded["seq"], decoded["actions"])
|
||||
slot.last_client_send_ms = decoded["client_send_ms"]
|
||||
return
|
||||
|
||||
|
||||
@@ -231,12 +236,15 @@ func _broadcast_snapshot() -> void:
|
||||
if is_instance_valid(ball):
|
||||
bodies.append(_ball_to_net_body_state(ball))
|
||||
var segment := NetCodec.pack_snapshot_body_segment(server_tick, 0, _reset_gen, bodies)
|
||||
# Per-client header fields (last_input_seq/input_buffer_depth/echo) aren't
|
||||
# tracked yet — that's the jitter-buffer work in Phase 3 (tasks 3.1-3.2).
|
||||
# Building the shared body segment once and reusing it per peer (rather
|
||||
# than re-encoding per client) is the whole reason §2.4 splits the wire
|
||||
# format into a per-client header + a shared body segment in the first
|
||||
# place — see pack_snapshot_body_segment's own doc comment.
|
||||
# place — see pack_snapshot_body_segment's own doc comment. The per-
|
||||
# client header (last_input_seq/input_buffer_depth/echo_client_send_ms)
|
||||
# is genuinely per-peer, built fresh below from each slot's own
|
||||
# InputJitterBuffer (§3.2) — last_applied_seq of -1 (nothing consumed
|
||||
# yet) encodes as 0 on the wire, which is safe: the client's own seq
|
||||
# numbering starts at 1, so 0 never collides with a real seq.
|
||||
# "No ship is ever despawned" (§6.4) means _slots outlives a disconnect —
|
||||
# a real one will be handled by Phase 5's reconnect/controller-swap
|
||||
# logic, but sending an RPC to a peer_id ENet no longer knows about
|
||||
@@ -246,7 +254,9 @@ func _broadcast_snapshot() -> void:
|
||||
var connected_peers := multiplayer.get_peers()
|
||||
for slot in _slots:
|
||||
if connected_peers.has(slot.peer_id):
|
||||
MatchSim.send_snapshot(slot.peer_id, NetCodec.pack_snapshot(0, 0, 0, segment))
|
||||
var last_input_seq := maxi(slot.jitter_buffer.last_applied_seq, 0)
|
||||
var bytes := NetCodec.pack_snapshot(last_input_seq, slot.jitter_buffer.depth(), slot.last_client_send_ms, segment)
|
||||
MatchSim.send_snapshot(slot.peer_id, bytes)
|
||||
|
||||
|
||||
func _ship_to_net_body_state(ship: Ship) -> NetBodyState:
|
||||
@@ -341,7 +351,16 @@ func _send_local_input() -> void:
|
||||
return # match_config hasn't arrived yet
|
||||
var action := _local_input_sampler.get_action().copy()
|
||||
_input_seq += 1
|
||||
var bytes := NetCodec.pack_input(_input_seq, 0, Time.get_ticks_msec(), [action])
|
||||
# Redundancy (§3.1): carry the last MAX_REDUNDANCY ticks' actions,
|
||||
# newest-first, so a burst of up to (MAX_REDUNDANCY - 1) consecutive
|
||||
# packet losses still lets the server recover every dropped tick's
|
||||
# action from a later packet — InputJitterBuffer.ingest() discards
|
||||
# whichever of these the server already applied, so re-sending old
|
||||
# ticks every packet is harmless, not just tolerated.
|
||||
_input_history.push_front(action)
|
||||
if _input_history.size() > NetCodec.MAX_REDUNDANCY:
|
||||
_input_history.resize(NetCodec.MAX_REDUNDANCY)
|
||||
var bytes := NetCodec.pack_input(_input_seq, _last_received_snapshot_tick, Time.get_ticks_msec(), _input_history)
|
||||
MatchSim.send_input(bytes)
|
||||
|
||||
|
||||
@@ -349,6 +368,7 @@ func _on_snapshot_received(decoded: Dictionary) -> void:
|
||||
var server_tick: int = decoded["server_tick"]
|
||||
var reset_gen: int = decoded["reset_gen"]
|
||||
var bodies: Array = decoded["bodies"]
|
||||
_last_received_snapshot_tick = server_tick
|
||||
_update_tick_bias(server_tick)
|
||||
for i in _slots.size():
|
||||
if i < bodies.size():
|
||||
@@ -418,6 +438,16 @@ func _physics_process(_delta: float) -> void:
|
||||
if _owns_world_simulation():
|
||||
_respawn_escaped_bodies()
|
||||
if multiplayer.is_server():
|
||||
# Once per tick, before the step (§3.2) — RLShipController reads
|
||||
# .action lazily in the ship's own _integrate_forces, which for this
|
||||
# tick already ran (physics step precedes _physics_process, §9
|
||||
# gotcha 34), so this actually takes effect on the NEXT tick's step.
|
||||
# That's the same one-tick input latency Phase 2 already had; this
|
||||
# just replaces "read the newest packet naively" with a real
|
||||
# sequence-tracked ring buffer that survives redundant/reordered/
|
||||
# lost packets.
|
||||
for slot in _slots:
|
||||
slot.controller.action = slot.jitter_buffer.consume()
|
||||
if _pending_reset_gen_bump and Engine.get_physics_frames() > _pending_reset_gen_bump_tick:
|
||||
_reset_gen = (_reset_gen + 1) % 256
|
||||
_pending_reset_gen_bump = false
|
||||
|
||||
Reference in New Issue
Block a user