Improve multiplayer movement reconciliation

This commit is contained in:
Alexander Sellite 2026-08-23 23:04:14 -04:00
parent 348ae261e1
commit a3aea98982
3 changed files with 240 additions and 89 deletions

View file

@ -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,12 +2250,15 @@ 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(
@ -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()

View file

@ -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
)
if correction_distance <= LOCAL_PREDICTION_SNAP_DISTANCE:
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
)
else:
_local_reconciliation_camera_offset = (
_camera_yaw.position - base_camera_local_position
)
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
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()
_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(

View file

@ -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"]))