Files
tactical-shooter/addons/netfox/network-time-synchronizer.gd
T
shawn 597d6dde2d Fix server clock reset in NetworkTimeSynchronizer and client-side peer 1 avatar duplicate
- Move _clock.set_time(0.) inside the 'if not server' block in
  NetworkTimeSynchronizer.start() so the server's SystemClock isn't
  reset to zero — fixes -1.78 billion second offset panic on client
- Skip spawning peer 1's avatar on clients (the server replicates
  all avatars; spawning peer 1 locally creates a duplicate that
  the dedicated server doesn't have)
2026-07-03 16:21:35 -04:00

285 lines
9.1 KiB
GDScript

extends Node
class_name _NetworkTimeSynchronizer
## Continuously synchronizes time to the host's remote clock.
##
## Make sure to read the [i]NetworkTimeSynchronizer Guide[/i] to understand the
## different clocks that the class docs refer to.
##
## @tutorial(NetworkTimeSynchronizer Guide): https://foxssake.github.io/netfox/netfox/guides/network-time-synchronizer/
## The minimum time in seconds between two sync samples.
##
## See [member sync_interval]
const MIN_SYNC_INTERVAL := 0.1
## Time between sync samples, in seconds.
## Cannot be less than [member MIN_SYNC_INTERVAL]
## [br][br]
## [i]read-only[/i], you can change this in the Netfox project settings
var sync_interval: float:
get:
return maxf(
_sync_interval,
MIN_SYNC_INTERVAL
)
set(v):
push_error("Trying to set read-only variable sync_interval")
## Number of measurements ( samples ) to use for time synchronization.
## [br][br]
## [i]read-only[/i], you can change this in the Netfox project settings
var sync_samples: int:
get:
return _sync_samples
set(v):
push_error("Trying to set read-only variable sync_samples")
## Number of iterations to nudge towards the host's remote clock.
##
## Lower values result in more aggressive changes in clock and may be more
## sensitive to jitter. Larger values may end up approaching the remote clock
## too slowly.
## [br][br]
## [i]read-only[/i], you can change this in the Netfox project settings
var adjust_steps: int:
get:
return _adjust_steps
set(v):
push_error("Trying to set read-only variable adjust_steps")
## Largest tolerated offset from the host's remote clock before panicking.
##
## Once this threshold is reached, the clock will be reset to the remote clock's
## value, and the nudge process will start from scratch.
## [br][br]
## [i]read-only[/i], you can change this in the Netfox project settings
var panic_threshold: float:
get:
return _panic_threshold
set(v):
push_error("Trying to set read-only variable panic_threshold")
## Measured roundtrip time measured to the host.
##
## This value is calculated from multiple samples. The actual roundtrip times
## can be anywhere in the [member rtt] +/- [member rtt_jitter] range.
## [br][br]
## [i]read-only[/i]
var rtt: float:
get:
return _rtt
set(v):
push_error("Trying to set read-only variable rtt")
## Measured jitter in the roundtrip time to the host remote.
##
## This value is calculated from multiple samples. The actual roundtrip times
## can be anywhere in the [member rtt] +/- [member rtt_jitter] range.
## [br][br]
## [i]read-only[/i]
var rtt_jitter: float:
get:
return _rtt_jitter
set(v):
push_error("Trying to set read-only variable rtt_jitter")
## Estimated offset from the host's remote clock.
##
## Positive values mean that the host's remote clock is ahead of ours, while
## negative values mean that our clock is behind the host's remote.
## [br][br]
## [i]read-only[/i]
var remote_offset: float:
get:
return _offset
set(v):
push_error("Trying to set read-only variable remote_offset")
# Settings
var _sync_interval: float = ProjectSettings.get_setting(&"netfox/time/sync_interval", 0.25)
var _sync_samples: int = ProjectSettings.get_setting(&"netfox/time/sync_samples", 8)
var _adjust_steps: int =ProjectSettings.get_setting(&"netfox/time/sync_adjust_steps", 8)
var _panic_threshold: float = ProjectSettings.get_setting(&"netfox/time/recalibrate_threshold", 2.)
var _active: bool = false
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("NetworkTimeSynchronizer")
# Samples are stored in a ring buffer
var _sample_buffer: _RingBuffer
var _sample_idx: int = 0
var _awaiting_samples: Dictionary = {}
var _clock: NetworkClocks.SystemClock = NetworkClocks.SystemClock.new()
var _offset: float = 0.
var _rtt: float = 0.
var _rtt_jitter: float = 0.
@onready var _cmd_ping := NetworkCommandServer.register_command(_handle_ping, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
@onready var _cmd_pong := NetworkCommandServer.register_command(_handle_pong, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
@onready var _cmd_req_time := NetworkCommandServer.register_command(_handle_request_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE)
@onready var _cmd_set_time := NetworkCommandServer.register_command(_handle_set_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE)
## Emitted after the initial time sync.
##
## At the start of the game, clients request an initial timestamp to kickstart
## their time sync loop. This event is emitted once that initial timestamp is
## received.
signal on_initial_sync()
## Emitted when clocks get overly out of sync and a time sync panic occurs.
##
## Panic means that the difference between clocks is too large. The time sync
## will reset the clock to the remote clock's time and restart the time sync loop
## from there.
## [br][br]
## Use this event in case you need to react to clock changes in your game.
signal on_panic(offset: float)
## Start the time synchronization loop.
##
## Starting multiple times has no effect.
func start() -> void:
if _active:
return
if not multiplayer.is_server():
_clock.set_time(0.)
_active = true
_sample_idx = 0
_sample_buffer = _RingBuffer.new(sync_samples)
_cmd_req_time.send(PackedByteArray(), 1)
## Stop the time synchronization loop.
func stop() -> void:
_active = false
## Get the current time from the reference clock.
##
## Returns a timestamp in seconds, with a fractional part for extra precision.
func get_time() -> float:
return _clock.get_time()
func _loop() -> void:
_logger.info("Time sync loop started! Initial timestamp: %ss", [_clock.get_time()])
on_initial_sync.emit()
while _active:
if multiplayer.is_server():
return stop()
var sample := NetworkClockSample.new()
_awaiting_samples[_sample_idx] = sample
sample.ping_sent = _clock.get_time()
_cmd_ping.send(var_to_bytes(_sample_idx), 1)
_sample_idx += 1
await get_tree().create_timer(sync_interval).timeout
func _discipline_clock() -> void:
var sorted_samples := _sample_buffer.get_data()
if sorted_samples.is_empty():
# Should never happen
_logger.warning("Trying to discipline the clock with no samples available!")
return
# Sort samples by latency
sorted_samples.sort_custom(
func(a: NetworkClockSample, b: NetworkClockSample):
return a.get_rtt() < b.get_rtt()
)
_logger.trace("Using sorted samples: \n%s", [
"\n".join(sorted_samples.map(func(it: NetworkClockSample): return "\t" + it.to_string() + " (%.4fs)" % [get_time() - it.ping_sent]))
])
# Calculate rtt bounds
var rtt_min := sorted_samples.front().get_rtt() as float
var rtt_max := sorted_samples.back().get_rtt() as float
_rtt = (rtt_max + rtt_min) / 2.
_rtt_jitter = (rtt_max - rtt_min) / 2.
# Calculate offset
var offset := 0.
var offsets := sorted_samples.map(func(it): return it.get_offset())
var offset_weight := 0.
for i in range(offsets.size()):
var w = log(1 + sorted_samples[i].get_rtt())
offset += offsets[i] * w
offset_weight += w
if not is_zero_approx(offset_weight):
offset /= offset_weight
else:
# RTT is so good it's basically zero, which means offset_weight is zero
# Use a simple average instead
offset /= sorted_samples.size()
# Panic / Adjust
if abs(offset) > panic_threshold:
# Reset clock, throw away all samples
_clock.adjust(offset)
_sample_buffer.clear()
# Also drop in-flight samples
_awaiting_samples.clear()
_offset = 0.
_logger.warning("Offset %ss is above panic threshold %ss! Resetting clock", [offset, panic_threshold])
on_panic.emit(offset)
else:
# Nudge clock towards estimated time
var nudge := offset / adjust_steps
_clock.adjust(nudge)
_logger.trace("Adjusted clock by %.2fms, offset: %.2fms, new time: %.4fss", [nudge * 1000., offset * 1000., _clock.get_time()])
_offset = offset - nudge
func _handle_ping(sender: int, data: PackedByteArray) -> void:
var idx := bytes_to_var(data) as int
var ping_received := _clock.get_time()
_cmd_pong.send(var_to_bytes([idx, ping_received, _clock.get_time()]), sender)
func _handle_pong(sender: int, data: PackedByteArray) -> void:
var args := bytes_to_var(data)
var idx := args[0] as int
var ping_received := args[1] as float
var pong_sent := args[2] as float
var pong_received := _clock.get_time()
if not _awaiting_samples.has(idx):
# Sample was dropped mid-flight during a panic episode
return
var sample := _awaiting_samples[idx] as NetworkClockSample
sample.ping_received = ping_received
sample.pong_sent = pong_sent
sample.pong_received = pong_received
_logger.trace("Received sample: %s", [sample])
# Once a sample is done, remove from in-flight samples and move to sample buffer
_awaiting_samples.erase(idx)
_sample_buffer.push(sample)
# Discipline clock based on new sample
_discipline_clock()
func _handle_request_timestamp(sender: int, data: PackedByteArray) -> void:
_logger.debug("Requested initial timestamp @ %.4fs raw time", [_clock.get_raw_time()])
_cmd_set_time.send(var_to_bytes(_clock.get_time()), sender)
func _handle_set_timestamp(sender: int, data: PackedByteArray) -> void:
var timestamp := bytes_to_var(data) as float
_logger.debug("Received initial timestamp @ %.4fs raw time", [_clock.get_raw_time()])
_clock.set_time(timestamp)
_loop()