Skip to main content

elura_netcode/
tick_sync.rs

1use std::time::Duration;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{NetcodeError, NetcodeResult};
6
7/// Client-side Tick estimation limits.
8#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
9#[serde(default, deny_unknown_fields)]
10#[non_exhaustive]
11pub struct TickSyncConfig {
12    /// Authoritative server simulation frequency.
13    pub tick_rate: u32,
14    /// Future input delay added after the estimated current server Tick.
15    pub input_delay_ticks: u64,
16    /// Exponential smoothing weight applied to each accepted sample in `0.0..=1.0`.
17    pub smoothing: f64,
18    /// Largest network round-trip duration accepted as a useful sample.
19    pub max_round_trip_time: Duration,
20    /// Largest Tick-offset correction accepted from one sample before smoothing.
21    pub max_offset_correction_ticks: f64,
22}
23
24impl Default for TickSyncConfig {
25    fn default() -> Self {
26        Self {
27            tick_rate: 30,
28            input_delay_ticks: 2,
29            smoothing: 0.2,
30            max_round_trip_time: Duration::from_secs(2),
31            max_offset_correction_ticks: 4.0,
32        }
33    }
34}
35
36impl TickSyncConfig {
37    /// Validates Tick rate, duration, and smoothing bounds.
38    pub fn validate(&self) -> NetcodeResult<()> {
39        if !(1..=240).contains(&self.tick_rate) {
40            return Err(NetcodeError::InvalidConfig(
41                "Tick rate must be within 1..=240",
42            ));
43        }
44        if !self.smoothing.is_finite() || self.smoothing <= 0.0 || self.smoothing > 1.0 {
45            return Err(NetcodeError::InvalidConfig(
46                "Tick smoothing must be within 0.0..=1.0",
47            ));
48        }
49        if self.max_round_trip_time.is_zero() {
50            return Err(NetcodeError::InvalidConfig(
51                "maximum round-trip time must be positive",
52            ));
53        }
54        if !self.max_offset_correction_ticks.is_finite() || self.max_offset_correction_ticks <= 0.0
55        {
56            return Err(NetcodeError::InvalidConfig(
57                "maximum Tick correction must be finite and positive",
58            ));
59        }
60        Ok(())
61    }
62}
63
64/// Client probe echoed by a Tick synchronization response.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66pub struct TickSyncRequest {
67    /// Client-selected probe sequence used to correlate the response.
68    pub sequence: u64,
69    /// Client monotonic time when this probe was sent.
70    pub client_sent_at: Duration,
71}
72
73/// Authoritative server response used to construct a [`TickSyncSample`].
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75pub struct TickSyncResponse {
76    /// Echoed client probe sequence.
77    pub sequence: u64,
78    /// Echoed client monotonic send time.
79    pub client_sent_at: Duration,
80    /// Server monotonic time when the probe was received.
81    pub server_received_at: Duration,
82    /// Server monotonic time when this response was sent.
83    pub server_sent_at: Duration,
84    /// Authoritative simulation Tick at `server_sent_at`.
85    pub server_tick: u64,
86}
87
88impl TickSyncResponse {
89    /// Converts the four probe timestamps into an estimator sample.
90    pub fn sample(
91        self,
92        request: TickSyncRequest,
93        client_received_at: Duration,
94        local_tick: f64,
95    ) -> NetcodeResult<TickSyncSample> {
96        if self.sequence != request.sequence || self.client_sent_at != request.client_sent_at {
97            return Err(NetcodeError::InvalidSample(
98                "Tick response does not match its request",
99            ));
100        }
101        let server_processing_time = self
102            .server_sent_at
103            .checked_sub(self.server_received_at)
104            .ok_or(NetcodeError::InvalidSample(
105                "server send time precedes receive time",
106            ))?;
107        Ok(TickSyncSample {
108            local_tick,
109            server_tick: self.server_tick,
110            client_sent_at: request.client_sent_at,
111            client_received_at,
112            server_processing_time,
113        })
114    }
115}
116
117/// One client observation of an authoritative server Tick.
118#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct TickSyncSample {
120    /// Client simulation Tick when the response was received; fractional values are allowed.
121    pub local_tick: f64,
122    /// Authoritative server Tick at response send time.
123    pub server_tick: u64,
124    /// Client monotonic time when the request was sent.
125    pub client_sent_at: Duration,
126    /// Client monotonic time when the response was received.
127    pub client_received_at: Duration,
128    /// Time spent processing the probe on the server.
129    pub server_processing_time: Duration,
130}
131
132/// Updated estimates returned after accepting one Tick sample.
133#[derive(Debug, Clone, Copy, PartialEq)]
134pub struct TickSyncReport {
135    /// Total client-observed request/response duration.
136    pub round_trip_time: Duration,
137    /// Round-trip duration after subtracting server processing time.
138    pub network_round_trip_time: Duration,
139    /// Estimated one-way network delay.
140    pub one_way_delay: Duration,
141    /// Unsmoothed server-minus-client Tick offset from this sample.
142    pub raw_offset_ticks: f64,
143    /// Current smoothed server-minus-client Tick offset.
144    pub offset_ticks: f64,
145    /// Estimated server Tick at client response receipt.
146    pub estimated_server_tick: f64,
147    /// Recommended future authoritative Tick for the next client input.
148    pub recommended_input_tick: u64,
149}
150
151/// Client-side estimator for authoritative server Tick position.
152#[derive(Debug, Clone)]
153pub struct TickSynchronizer {
154    config: TickSyncConfig,
155    offset_ticks: f64,
156    smoothed_network_rtt_seconds: f64,
157    samples: u64,
158}
159
160impl TickSynchronizer {
161    /// Creates an estimator with no accepted samples.
162    pub fn new(config: TickSyncConfig) -> NetcodeResult<Self> {
163        config.validate()?;
164        Ok(Self {
165            config,
166            offset_ticks: 0.0,
167            smoothed_network_rtt_seconds: 0.0,
168            samples: 0,
169        })
170    }
171
172    /// Observes one response and updates RTT and Tick-offset estimates.
173    ///
174    /// The server Tick is defined at response send time. Half the network RTT is therefore added
175    /// before comparing it with the client's Tick at response receipt.
176    pub fn observe(&mut self, sample: TickSyncSample) -> NetcodeResult<TickSyncReport> {
177        if !sample.local_tick.is_finite() || sample.local_tick < 0.0 {
178            return Err(NetcodeError::InvalidSample(
179                "local Tick must be finite and non-negative",
180            ));
181        }
182        let round_trip_time = sample
183            .client_received_at
184            .checked_sub(sample.client_sent_at)
185            .ok_or(NetcodeError::InvalidSample(
186                "client receive time precedes send time",
187            ))?;
188        let network_round_trip_time = round_trip_time
189            .checked_sub(sample.server_processing_time)
190            .ok_or(NetcodeError::InvalidSample(
191                "server processing exceeds total round-trip time",
192            ))?;
193        if network_round_trip_time > self.config.max_round_trip_time {
194            return Err(NetcodeError::InvalidSample(
195                "network round-trip time exceeds the configured maximum",
196            ));
197        }
198
199        let network_seconds = network_round_trip_time.as_secs_f64();
200        let one_way_seconds = network_seconds / 2.0;
201        let estimated_server_tick =
202            sample.server_tick as f64 + one_way_seconds * f64::from(self.config.tick_rate);
203        let raw_offset_ticks = estimated_server_tick - sample.local_tick;
204
205        if self.samples == 0 {
206            self.offset_ticks = raw_offset_ticks;
207            self.smoothed_network_rtt_seconds = network_seconds;
208        } else {
209            let correction = (raw_offset_ticks - self.offset_ticks).clamp(
210                -self.config.max_offset_correction_ticks,
211                self.config.max_offset_correction_ticks,
212            );
213            self.offset_ticks += correction * self.config.smoothing;
214            self.smoothed_network_rtt_seconds +=
215                (network_seconds - self.smoothed_network_rtt_seconds) * self.config.smoothing;
216        }
217        self.samples = self.samples.saturating_add(1);
218
219        Ok(TickSyncReport {
220            round_trip_time,
221            network_round_trip_time,
222            one_way_delay: Duration::from_secs_f64(one_way_seconds),
223            raw_offset_ticks,
224            offset_ticks: self.offset_ticks,
225            estimated_server_tick: self.estimate_server_tick(sample.local_tick),
226            recommended_input_tick: self.recommended_input_tick(sample.local_tick),
227        })
228    }
229
230    /// Estimates the server's fractional Tick at a given local fractional Tick.
231    pub fn estimate_server_tick(&self, local_tick: f64) -> f64 {
232        if !local_tick.is_finite() {
233            return 0.0;
234        }
235        (local_tick + self.offset_ticks).max(0.0)
236    }
237
238    /// Chooses the next authoritative input Tick using the configured input delay.
239    pub fn recommended_input_tick(&self, local_tick: f64) -> u64 {
240        let estimated = self.estimate_server_tick(local_tick).floor();
241        let current = if estimated >= u64::MAX as f64 {
242            u64::MAX
243        } else {
244            estimated as u64
245        };
246        current
247            .saturating_add(self.config.input_delay_ticks)
248            .saturating_add(1)
249    }
250
251    /// Returns the smoothed server-minus-client Tick offset.
252    pub fn offset_ticks(&self) -> f64 {
253        self.offset_ticks
254    }
255
256    /// Returns the smoothed network RTT after subtracting server processing.
257    pub fn network_round_trip_time(&self) -> Option<Duration> {
258        (self.samples > 0)
259            .then(|| Duration::from_secs_f64(self.smoothed_network_rtt_seconds.max(0.0)))
260    }
261
262    /// Returns the number of accepted synchronization samples.
263    pub fn samples(&self) -> u64 {
264        self.samples
265    }
266}