steel_core/server/
tick_rate_manager.rs1use std::time::Instant;
2
3const TICK_STATS_SPAN: usize = 100;
5
6const NANOS_PER_MS: f64 = 1_000_000.0;
8
9const NANOS_PER_SEC: f64 = 1_000_000_000.0;
11
12const MS_PER_SEC: f64 = 1000.0;
14
15const TICK_TIME_SMOOTHING: f32 = 0.8;
17
18#[derive(Debug, Clone)]
20pub struct SprintReport {
21 pub ticks_per_second: i32,
23 pub ms_per_tick: f64,
25}
26
27pub struct TickRateManager {
29 pub tick_rate: f32,
31 pub nanoseconds_per_tick: u64,
33 pub tick_count: u64,
35 is_frozen: bool,
37 frozen_ticks_to_run: i32,
39 run_game_elements: bool,
41
42 remaining_sprint_ticks: i64,
45 scheduled_current_sprint_ticks: i64,
47 sprint_tick_start_time: Option<Instant>,
49 sprint_time_spent: i64,
51 previous_is_frozen: bool,
53
54 tick_times_nanos: [u64; TICK_STATS_SPAN],
57 aggregated_tick_times_nanos: u64,
59 smoothed_tick_time_ms: f32,
61}
62
63impl TickRateManager {
64 #[must_use]
66 pub const fn new() -> Self {
67 Self {
68 tick_rate: 20.0,
69 nanoseconds_per_tick: 50_000_000, tick_count: 0,
71 is_frozen: false,
72 frozen_ticks_to_run: 0,
73 run_game_elements: true,
74 remaining_sprint_ticks: 0,
75 scheduled_current_sprint_ticks: 0,
76 sprint_tick_start_time: None,
77 sprint_time_spent: 0,
78 previous_is_frozen: false,
79 tick_times_nanos: [0; TICK_STATS_SPAN],
80 aggregated_tick_times_nanos: 0,
81 smoothed_tick_time_ms: 0.0,
82 }
83 }
84
85 pub fn set_tick_rate(&mut self, rate: f32) {
87 self.tick_rate = rate.max(1.0);
88 #[expect(
89 clippy::cast_possible_truncation,
90 clippy::cast_sign_loss,
91 reason = "result is always a positive sub-second nanosecond count, fits in u64"
92 )]
93 {
94 self.nanoseconds_per_tick = (NANOS_PER_SEC / f64::from(self.tick_rate)) as u64;
95 }
96 }
97
98 #[must_use]
100 pub const fn tick_rate(&self) -> f32 {
101 self.tick_rate
102 }
103
104 #[must_use]
106 pub fn milliseconds_per_tick(&self) -> f32 {
107 self.nanoseconds_per_tick as f32 / NANOS_PER_MS as f32
108 }
109
110 pub const fn set_frozen(&mut self, frozen: bool) {
112 self.is_frozen = frozen;
113 }
114
115 #[must_use]
117 pub const fn is_frozen(&self) -> bool {
118 self.is_frozen
119 }
120
121 #[must_use]
123 pub const fn is_stepping_forward(&self) -> bool {
124 self.frozen_ticks_to_run > 0
125 }
126
127 #[must_use]
129 pub const fn frozen_ticks_to_run(&self) -> i32 {
130 self.frozen_ticks_to_run
131 }
132
133 #[must_use]
135 pub const fn runs_normally(&self) -> bool {
136 self.run_game_elements
137 }
138
139 pub const fn tick(&mut self) {
142 self.run_game_elements = !self.is_frozen || self.frozen_ticks_to_run > 0;
143 if self.frozen_ticks_to_run > 0 {
144 self.frozen_ticks_to_run -= 1;
145 }
146 }
147
148 pub const fn increment_tick_count(&mut self) {
150 self.tick_count += 1;
151 }
152
153 pub const fn step_game_if_paused(&mut self, ticks: i32) -> bool {
158 if !self.is_frozen {
159 return false;
160 }
161 self.frozen_ticks_to_run = ticks;
162 true
163 }
164
165 pub const fn stop_stepping(&mut self) -> bool {
168 if self.frozen_ticks_to_run > 0 {
169 self.frozen_ticks_to_run = 0;
170 true
171 } else {
172 false
173 }
174 }
175
176 #[must_use]
180 pub const fn is_sprinting(&self) -> bool {
181 self.scheduled_current_sprint_ticks > 0
182 }
183
184 pub fn request_game_to_sprint(&mut self, ticks: i32) -> bool {
187 let interrupted = self.remaining_sprint_ticks > 0;
188 self.sprint_time_spent = 0;
189 self.scheduled_current_sprint_ticks = i64::from(ticks);
190 self.remaining_sprint_ticks = i64::from(ticks);
191 self.previous_is_frozen = self.is_frozen;
192 self.set_frozen(false);
193 interrupted
194 }
195
196 pub fn stop_sprinting(&mut self) -> Option<SprintReport> {
199 if self.remaining_sprint_ticks > 0 {
200 Some(self.finish_tick_sprint())
201 } else {
202 None
203 }
204 }
205
206 pub fn check_should_sprint_this_tick(&mut self) -> (bool, Option<SprintReport>) {
210 if !self.run_game_elements {
211 return (false, None);
212 }
213 if self.remaining_sprint_ticks > 0 {
214 self.sprint_tick_start_time = Some(Instant::now());
215 self.remaining_sprint_ticks -= 1;
216 (true, None)
217 } else if self.scheduled_current_sprint_ticks > 0 {
218 (false, Some(self.finish_tick_sprint()))
220 } else {
221 (false, None)
222 }
223 }
224
225 pub fn end_tick_work(&mut self) {
228 if let Some(start) = self.sprint_tick_start_time.take() {
229 self.sprint_time_spent += start.elapsed().as_nanos() as i64;
230 }
231 }
232
233 fn finish_tick_sprint(&mut self) -> SprintReport {
235 let completed_ticks = self.scheduled_current_sprint_ticks - self.remaining_sprint_ticks;
236 let time_spent_ms = (self.sprint_time_spent.max(1) as f64) / NANOS_PER_MS;
237
238 #[expect(
239 clippy::cast_possible_truncation,
240 reason = "TPS fits well within i32 range"
241 )]
242 let ticks_per_second = (MS_PER_SEC * completed_ticks as f64 / time_spent_ms) as i32;
243 let ms_per_tick = if completed_ticks == 0 {
244 f64::from(self.milliseconds_per_tick())
245 } else {
246 time_spent_ms / completed_ticks as f64
247 };
248
249 self.scheduled_current_sprint_ticks = 0;
250 self.sprint_time_spent = 0;
251 self.remaining_sprint_ticks = 0;
252 self.set_frozen(self.previous_is_frozen);
253
254 SprintReport {
255 ticks_per_second,
256 ms_per_tick,
257 }
258 }
259
260 pub fn record_tick_time(&mut self, tick_time_nanos: u64) {
265 let tick_index = (self.tick_count as usize) % TICK_STATS_SPAN;
266
267 self.aggregated_tick_times_nanos -= self.tick_times_nanos[tick_index];
269 self.aggregated_tick_times_nanos += tick_time_nanos;
270 self.tick_times_nanos[tick_index] = tick_time_nanos;
271
272 let tick_time_ms = tick_time_nanos as f32 / NANOS_PER_MS as f32;
274 self.smoothed_tick_time_ms = self.smoothed_tick_time_ms * TICK_TIME_SMOOTHING
275 + tick_time_ms * (1.0 - TICK_TIME_SMOOTHING);
276 }
277
278 #[must_use]
280 pub fn get_average_tick_time_nanos(&self) -> u64 {
281 let sample_count = self.tick_count.min(TICK_STATS_SPAN as u64).max(1);
282 self.aggregated_tick_times_nanos / sample_count
283 }
284
285 #[must_use]
287 pub fn get_average_mspt(&self) -> f32 {
288 self.get_average_tick_time_nanos() as f32 / NANOS_PER_MS as f32
289 }
290
291 #[must_use]
293 pub const fn get_smoothed_mspt(&self) -> f32 {
294 self.smoothed_tick_time_ms
295 }
296
297 #[must_use]
300 pub fn get_tps(&self) -> f32 {
301 let mspt = self.get_average_mspt();
302 if mspt <= 0.0 {
303 return self.tick_rate;
304 }
305 (1000.0 / mspt).min(self.tick_rate)
307 }
308
309 #[must_use]
311 pub const fn get_tick_times_nanos(&self) -> [u64; TICK_STATS_SPAN] {
312 self.tick_times_nanos
313 }
314
315 #[must_use]
319 pub fn get_p50(&self) -> f32 {
320 self.get_percentile(50)
321 }
322
323 #[must_use]
325 pub fn get_p95(&self) -> f32 {
326 self.get_percentile(95)
327 }
328
329 #[must_use]
331 pub fn get_p99(&self) -> f32 {
332 self.get_percentile(99)
333 }
334
335 #[must_use]
337 pub fn get_sample_count(&self) -> usize {
338 (self.tick_count as usize).min(TICK_STATS_SPAN)
339 }
340
341 fn get_percentile(&self, percentile: u8) -> f32 {
343 let sample_count = self.get_sample_count();
344 if sample_count == 0 {
345 return 0.0;
346 }
347
348 let mut sorted = self.tick_times_nanos;
350 sorted[..sample_count].sort_unstable();
351
352 let idx = (sample_count * percentile as usize / 100).min(sample_count - 1);
353 sorted[idx] as f32 / NANOS_PER_MS as f32
354 }
355}
356
357impl Default for TickRateManager {
358 fn default() -> Self {
359 Self::new()
360 }
361}