From a3aea989827847c5c302144b11ef83c0363197c4 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 23 Aug 2026 23:04:14 -0400 Subject: [PATCH] Improve multiplayer movement reconciliation --- network/network_session.gd | 34 +++- player/player.gd | 222 +++++++++++++++-------- tests/movement_multiplayer_validation.gd | 73 +++++++- 3 files changed, 240 insertions(+), 89 deletions(-) diff --git a/network/network_session.gd b/network/network_session.gd index 11217d7..b889678 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -13,6 +13,7 @@ const ENET_TIMEOUT_MAXIMUM_MS: int = 120000 const INPUT_INTERVAL: float = 1.0 / 30.0 const IDLE_INPUT_INTERVAL: float = 1.0 / 5.0 const SNAPSHOT_INTERVAL: float = 1.0 / 30.0 +const OWNER_SNAPSHOT_DIVISOR: int = 3 const NEAR_REMOTE_SNAPSHOT_DIVISOR: int = 2 const FAR_REMOTE_SNAPSHOT_DIVISOR: int = 6 const DISTANT_REMOTE_SNAPSHOT_DIVISOR: int = 8 @@ -122,6 +123,7 @@ var _last_input_state_hash: int = 0 var _pending_movement_inputs: Array[Dictionary] = [] var _snapshot_accumulator: float = 0.0 var _movement_snapshot_tick: int = 0 +var _last_local_snapshot_received_msec: int = 0 var _animation_refresh_accumulator: float = 0.0 var _last_animation_state_by_peer: Dictionary[int, Dictionary] = {} var _pending_animation_state_by_peer: Dictionary[int, Dictionary] = {} @@ -2248,13 +2250,16 @@ func _broadcast_movement_snapshots() -> void: var subject_avatar: Player = _spawn_service.get_avatar(subject_id) if subject_avatar == null: continue - if ( - subject_id != recipient_id - and not _should_send_remote_snapshot( + if subject_id == recipient_id: + # The owner already simulates locally. Its authoritative state is + # an audit and acknowledgement, not a presentation stream, so it + # does not need the full 30 Hz observer snapshot rate. + if _movement_snapshot_tick % OWNER_SNAPSHOT_DIVISOR != 0: + continue + elif not _should_send_remote_snapshot( recipient_avatar.global_position, subject_avatar.global_position, - ) - ): + ): continue var encoded: Array = _encode_movement_snapshot( subject_avatar.make_network_snapshot(subject_id) @@ -2538,8 +2543,10 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void: ) avatar.apply_local_prediction_correction( snapshot, - _pending_movement_inputs, + _input_sequence, INPUT_INTERVAL, + estimated_transit_seconds, + _local_snapshot_delta_seconds(), ) else: avatar.push_network_snapshot( @@ -2557,6 +2564,20 @@ func _discard_acknowledged_movement_inputs(acknowledged_sequence: int) -> void: _pending_movement_inputs.pop_front() +func _local_snapshot_delta_seconds() -> float: + var now_msec: int = Time.get_ticks_msec() + if _last_local_snapshot_received_msec <= 0: + _last_local_snapshot_received_msec = now_msec + return 0.0 + var elapsed_seconds: float = clampf( + float(now_msec - _last_local_snapshot_received_msec) / 1000.0, + 0.0, + Player.LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS, + ) + _last_local_snapshot_received_msec = now_msec + return elapsed_seconds + + @rpc( "authority", "call_remote", @@ -2861,6 +2882,7 @@ func _teardown_peer() -> void: _pending_movement_inputs.clear() _snapshot_accumulator = 0.0 _movement_snapshot_tick = 0 + _last_local_snapshot_received_msec = 0 _animation_refresh_accumulator = 0.0 _last_animation_state_by_peer.clear() _pending_animation_state_by_peer.clear() diff --git a/player/player.gd b/player/player.gd index 6ec490f..860caf3 100644 --- a/player/player.gd +++ b/player/player.gd @@ -145,9 +145,14 @@ const NETWORK_MOVEMENT_HISTORY_SECONDS: float = 1.25 const NETWORK_MAX_LAG_COMPENSATION_SECONDS: float = 0.75 const LOCAL_PREDICTION_EXTRAPOLATION_LIMIT_SECONDS: float = 0.25 const LOCAL_PREDICTION_FALLBACK_TRANSIT_RATIO: float = 0.5 -const LOCAL_PREDICTION_CORRECTION_THRESHOLD: float = 0.12 -const LOCAL_PREDICTION_SNAP_DISTANCE: float = 2.0 -const LOCAL_PREDICTION_CORRECTION_WEIGHT: float = 0.18 +const LOCAL_PREDICTION_CORRECTION_THRESHOLD: float = 1.5 +const LOCAL_PREDICTION_SNAP_DISTANCE: float = 6.0 +const LOCAL_PREDICTION_CORRECTION_DELAY_SECONDS: float = 0.35 +const LOCAL_PREDICTION_MIN_CORRECTION_AUDITS: int = 3 +const LOCAL_PREDICTION_SOFT_CORRECTION_RATE: float = 2.5 +const LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP: float = 0.15 +const LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS: float = 0.15 +const LOCAL_RECONCILIATION_PRESENTATION_RECENTER_RATE: float = 5.0 # The target Android handheld exposes its physical right trigger through # Godot's left-trigger axis. Keep the role named here so the platform mapping # remains isolated from camera behavior. @@ -529,6 +534,13 @@ var _network_snapshot_age: float = 0.0 var _network_snapshot_jitter: float = 0.0 var _network_simulation_only: bool = false var _local_reconciliation_visual_offset: Vector3 = Vector3.ZERO +var _local_reconciliation_camera_offset: Vector3 = Vector3.ZERO +var _local_prediction_error_seconds: float = 0.0 +var _local_prediction_error_audits: int = 0 +var _local_prediction_error_direction: Vector3 = Vector3.ZERO +var _local_prediction_soft_corrections: int = 0 +var _local_prediction_hard_corrections: int = 0 +var _local_prediction_largest_error: float = 0.0 var _authoritative_movement_history: Array[Dictionary] = [] var _local_network_jump_intent_pending: bool = false var _local_network_jump_intent_sequence: int = -1 @@ -1995,6 +2007,11 @@ func reset_network_movement_state() -> void: _network_input_stale_timeout_seconds = NETWORK_INPUT_STALE_TIMEOUT_SECONDS _network_jump_intent_active = false _last_network_input_sequence = 0 + _reset_local_prediction_error() + _clear_local_reconciliation_offsets() + _local_prediction_soft_corrections = 0 + _local_prediction_hard_corrections = 0 + _local_prediction_largest_error = 0.0 func capture_network_input(sequence: int) -> Dictionary: @@ -2235,8 +2252,10 @@ func push_network_snapshot( func apply_local_prediction_correction( snapshot: Dictionary, - pending_inputs: Array[Dictionary] = [], + latest_input_sequence: int = 0, input_interval_seconds: float = 0.0, + estimated_transit_seconds: float = -1.0, + audit_delta_seconds: float = 0.0, ) -> void: var parsed: Dictionary = _parse_network_snapshot(snapshot) if parsed.is_empty(): @@ -2258,92 +2277,143 @@ func apply_local_prediction_correction( _clear_local_network_jump_intent() if not _sitting_intent_pending: _set_sitting(bool(parsed["sitting"])) + var authoritative_position: Vector3 = parsed["position"] + var transit_seconds: float = resolve_local_prediction_transit_seconds( + acknowledged_input, + latest_input_sequence, + input_interval_seconds, + estimated_transit_seconds, + ) + if transit_seconds > 0.0: + authoritative_position += ( + (parsed["velocity"] as Vector3) * transit_seconds + ) + var error_offset: Vector3 = authoritative_position - global_position + var error_distance: float = error_offset.length() + _local_prediction_largest_error = maxf( + _local_prediction_largest_error, + error_distance, + ) + if error_distance <= LOCAL_PREDICTION_CORRECTION_THRESHOLD: + _reset_local_prediction_error() + return + if error_distance >= LOCAL_PREDICTION_SNAP_DISTANCE: + _clear_local_reconciliation_offsets() + global_position = authoritative_position + velocity = parsed["velocity"] + _local_prediction_hard_corrections += 1 + _reset_local_prediction_error() + return + var error_direction: Vector3 = error_offset.normalized() + if ( + not _local_prediction_error_direction.is_zero_approx() + and _local_prediction_error_direction.dot(error_direction) < 0.5 + ): + _reset_local_prediction_error() + _local_prediction_error_direction = error_direction + _local_prediction_error_audits += 1 + _local_prediction_error_seconds += clampf( + audit_delta_seconds, + 0.0, + LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS, + ) + if ( + _local_prediction_error_audits + < LOCAL_PREDICTION_MIN_CORRECTION_AUDITS + or _local_prediction_error_seconds + < LOCAL_PREDICTION_CORRECTION_DELAY_SECONDS + ): + return + var correction_weight: float = 1.0 - exp( + -LOCAL_PREDICTION_SOFT_CORRECTION_RATE + * clampf( + audit_delta_seconds, + 0.0, + LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS, + ) + ) + var correction: Vector3 = error_offset * correction_weight + if correction.length() > LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP: + correction = ( + correction.normalized() + * LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP + ) + _apply_camera_safe_local_correction(correction) + _local_prediction_soft_corrections += 1 + + +func _apply_camera_safe_local_correction(correction: Vector3) -> void: + if correction.is_zero_approx(): + return var previous_visual_position: Vector3 = _visuals.global_position + var previous_camera_position: Vector3 = _camera_yaw.global_position var base_visual_local_position: Vector3 = ( _visuals.position - _local_reconciliation_visual_offset ) - var previous_position: Vector3 = global_position - global_position = parsed["position"] - velocity = parsed["velocity"] - if input_interval_seconds > 0.0: - for input: Dictionary in pending_inputs: - _replay_network_movement_input(input, input_interval_seconds) - var correction_distance: float = previous_position.distance_to( - global_position + var base_camera_local_position: Vector3 = ( + _camera_yaw.position - _local_reconciliation_camera_offset + ) + global_position += correction + _visuals.global_position = previous_visual_position + _camera_yaw.global_position = previous_camera_position + _local_reconciliation_visual_offset = ( + _visuals.position - base_visual_local_position + ) + _local_reconciliation_camera_offset = ( + _camera_yaw.position - base_camera_local_position ) - if correction_distance <= LOCAL_PREDICTION_SNAP_DISTANCE: - _visuals.global_position = previous_visual_position - _local_reconciliation_visual_offset = ( - _visuals.position - base_visual_local_position - ) - else: - _local_reconciliation_visual_offset = Vector3.ZERO -func _replay_network_movement_input( - data: Dictionary, - delta: float, -) -> void: - var axis_value: Variant = data.get("axis", []) - if typeof(axis_value) != TYPE_ARRAY or axis_value.size() != 2: - return - if bool(data.get("sitting", false)) or _water_recovery_active: - velocity = Vector3.ZERO - return - var input_vector := Vector2( - float(axis_value[0]), - float(axis_value[1]), - ).limit_length(1.0) - var camera_basis := Basis( - Vector3.UP, - float(data.get("camera_yaw", 0.0)), - ) - var move_direction: Vector3 = ( - camera_basis.x * input_vector.x - + camera_basis.z * input_vector.y - ) - move_direction.y = 0.0 - move_direction = move_direction.normalized() - _network_sprint = bool(data.get("sprint", false)) - _network_sneak = bool(data.get("sneak", false)) - _network_slow_walk = bool(data.get("slow_walk", false)) - # Replay the speed authored by this exact pending input. Consulting the - # current InputMap here would make an older walk replay as a sprint (or the - # reverse) whenever the local button changed while a snapshot was in flight. - var replay_speed: float = walk_speed - if _network_sneak: - replay_speed = sneak_speed - elif _network_slow_walk: - replay_speed = slow_walk_speed - elif _network_sprint: - replay_speed = sprint_speed - if item_effects != null: - replay_speed *= item_effects.get_movement_multiplier() - var input_strength: float = minf(input_vector.length(), 1.0) - velocity.x = move_direction.x * replay_speed * input_strength - velocity.z = move_direction.z * replay_speed * input_strength - if not is_on_floor(): - var gravity_multiplier: float = ( - upward_gravity_multiplier - if velocity.y > 0.0 - else fall_gravity_multiplier - ) - velocity.y -= _gravity * gravity_multiplier * delta - elif bool(data.get("jump", false)): - velocity.y = jump_velocity - move_and_slide() +func _reset_local_prediction_error() -> void: + _local_prediction_error_seconds = 0.0 + _local_prediction_error_audits = 0 + _local_prediction_error_direction = Vector3.ZERO + + +func _clear_local_reconciliation_offsets() -> void: + if not _local_reconciliation_visual_offset.is_zero_approx(): + _visuals.position -= _local_reconciliation_visual_offset + if not _local_reconciliation_camera_offset.is_zero_approx(): + _camera_yaw.position -= _local_reconciliation_camera_offset + _local_reconciliation_visual_offset = Vector3.ZERO + _local_reconciliation_camera_offset = Vector3.ZERO func _update_local_reconciliation_visuals(delta: float) -> void: - if _local_reconciliation_visual_offset.is_zero_approx(): + if ( + _local_reconciliation_visual_offset.is_zero_approx() + and _local_reconciliation_camera_offset.is_zero_approx() + ): _local_reconciliation_visual_offset = Vector3.ZERO + _local_reconciliation_camera_offset = Vector3.ZERO return - var retained_ratio: float = exp(-14.0 * delta) - var retained_offset: Vector3 = ( + var retained_ratio: float = exp( + -LOCAL_RECONCILIATION_PRESENTATION_RECENTER_RATE * delta + ) + var retained_visual_offset: Vector3 = ( _local_reconciliation_visual_offset * retained_ratio ) - _visuals.position += retained_offset - _local_reconciliation_visual_offset - _local_reconciliation_visual_offset = retained_offset + var retained_camera_offset: Vector3 = ( + _local_reconciliation_camera_offset * retained_ratio + ) + _visuals.position += ( + retained_visual_offset - _local_reconciliation_visual_offset + ) + _camera_yaw.position += ( + retained_camera_offset - _local_reconciliation_camera_offset + ) + _local_reconciliation_visual_offset = retained_visual_offset + _local_reconciliation_camera_offset = retained_camera_offset + + +func get_local_prediction_metrics() -> Dictionary: + return { + "soft_corrections": _local_prediction_soft_corrections, + "hard_corrections": _local_prediction_hard_corrections, + "largest_error": _local_prediction_largest_error, + "out_of_bounds_audits": _local_prediction_error_audits, + "out_of_bounds_seconds": _local_prediction_error_seconds, + } static func resolve_network_input_stale_timeout_seconds( diff --git a/tests/movement_multiplayer_validation.gd b/tests/movement_multiplayer_validation.gd index f331534..c6a98e5 100644 --- a/tests/movement_multiplayer_validation.gd +++ b/tests/movement_multiplayer_validation.gd @@ -71,6 +71,7 @@ func _validate_compact_snapshot_encoding() -> void: 1, ) assert(NetworkSession.MOVEMENT_SNAPSHOT_BATCH_SIZE == 8) + assert(NetworkSession.OWNER_SNAPSHOT_DIVISOR == 3) var encoded_snapshots: Array = [] for _peer: int in NetworkSession.MOVEMENT_SNAPSHOT_BATCH_SIZE: encoded_snapshots.append( @@ -283,16 +284,72 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void: avatar.set_local_control(true) avatar.global_position = Vector3(0.8, 0.0, 0.0) - var replay_input: Dictionary = _movement_input(2, false) - replay_input["axis"] = [1.0, 0.0] - var pending_inputs: Array[Dictionary] = [replay_input] avatar.apply_local_prediction_correction( moving_snapshot, - pending_inputs, + 2, 1.0 / 30.0, + 0.1, + 0.1, + ) + # Ordinary host/client disagreement is expected while a packet is in flight. + # It must never tug the locally controlled body or camera around. + assert(is_equal_approx(avatar.global_position.x, 0.8)) + assert( + int(avatar.get("_local_prediction_soft_corrections")) == 0 + ) + assert( + int(avatar.get("_local_prediction_hard_corrections")) == 0 + ) + + # A larger but still plausible mismatch must persist across multiple audits + # before a small correction is allowed. Preserve both visible character and + # camera positions while the collision body catches up. + avatar.global_position = Vector3(3.0, 0.0, 0.0) + var visual_position: Vector3 = avatar.get_node("Visuals").global_position + var camera_position: Vector3 = avatar.get_node("CameraYaw").global_position + var drift_snapshot: Dictionary = _network_snapshot( + Vector3.ZERO, + Vector3.ZERO, + 20, + ) + for _audit: int in 3: + avatar.apply_local_prediction_correction( + drift_snapshot, + 20, + 1.0 / 30.0, + 0.0, + 0.1, + ) + assert(is_equal_approx(avatar.global_position.x, 3.0)) + avatar.apply_local_prediction_correction( + drift_snapshot, + 20, + 1.0 / 30.0, + 0.0, + 0.1, + ) + assert(avatar.global_position.x < 3.0) + assert(avatar.global_position.x >= 2.85 - 0.001) + assert(avatar.get_node("Visuals").global_position == visual_position) + assert(avatar.get_node("CameraYaw").global_position == camera_position) + assert( + int(avatar.get("_local_prediction_soft_corrections")) == 1 + ) + + # Genuine divergence still recovers immediately, as do the separate reliable + # teleport and water-recovery paths used by gameplay transitions. + avatar.global_position = Vector3(10.0, 0.0, 0.0) + avatar.apply_local_prediction_correction( + drift_snapshot, + 20, + 1.0 / 30.0, + 0.0, + 0.1, + ) + assert(avatar.global_position == Vector3.ZERO) + assert( + int(avatar.get("_local_prediction_hard_corrections")) == 1 ) - assert(avatar.global_position.x < 0.8) - assert(avatar.global_position.x > 0.0) func _validate_reliable_jump_intent(avatar: Player) -> void: @@ -305,8 +362,10 @@ func _validate_reliable_jump_intent(avatar: Player) -> void: assert(int(avatar.get("_local_network_jump_intent_sequence")) == 20) avatar.apply_local_prediction_correction( _network_snapshot(avatar.global_position, Vector3.ZERO, 20), - [], + 20, 1.0 / 30.0, + 0.0, + 0.1, ) assert(not bool(avatar.capture_network_input(22)["jump"]))