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()