steel_core/server/
tick_rate_manager.rs1use std::time::Instant;
2use steel_utils::TICKS_PER_SECOND;
3
4const TICK_STATS_SPAN: usize = 100;
6
7const NANOS_PER_MS: f64 = 1_000_000.0;
9
10const NANOS_PER_SEC: f64 = 1_000_000_000.0;
12
13const MS_PER_SEC: f64 = 1000.0;
15
16const TICK_TIME_SMOOTHING: f32 = 0.8;
18
19#[derive(Debug, Clone)]
21pub struct SprintReport {
22 pub ticks_per_second: i32,
24 pub ms_per_tick: f64,
26}
27
28pub struct TickRateManager {
30 pub tick_rate: f32,
32 pub nanoseconds_per_tick: u64,
34 pub tick_count: u64,
36 is_frozen: bool,
38 frozen_ticks_to_run: i32,
40 run_game_elements: bool,
42
43 remaining_sprint_ticks: i64,
46 scheduled_current_sprint_ticks: i64,
48 sprint_tick_start_time: Option<Instant>,
50 sprint_time_spent: i64,
52 previous_is_frozen: bool,
54
55 tick_times_nanos: [u64; TICK_STATS_SPAN],
58 aggregated_tick_times_nanos: u64,
60 smoothed_tick_time_ms: f32,
62}
63
64impl TickRateManager {
65 #[must_use]
67 pub const fn new() -> Self {
68 Self {
69 tick_rate: TICKS_PER_SECOND as f32,
70 nanoseconds_per_tick: (NANOS_PER_SEC / TICKS_PER_SECOND as f64) as u64,
71 tick_count: 0,
72 is_frozen: false,
73 frozen_ticks_to_run: 0,
74 run_game_elements: true,
75 remaining_sprint_ticks: 0,
76 scheduled_current_sprint_ticks: 0,
77 sprint_tick_start_time: None,
78 sprint_time_spent: 0,
79 previous_is_frozen: false,
80 tick_times_nanos: [0; TICK_STATS_SPAN],
81 aggregated_tick_times_nanos: 0,
82 smoothed_tick_time_ms: 0.0,
83 }
84 }
85
86 pub fn set_tick_rate(&mut self, rate: f32) {
88 self.tick_rate = rate.max(1.0);
89 #[expect(
90 clippy::cast_possible_truncation,
91 clippy::cast_sign_loss,
92 reason = "result is always a positive sub-second nanosecond count, fits in u64"
93 )]
94 {
95 self.nanoseconds_per_tick = (NANOS_PER_SEC / f64::from(self.tick_rate)) as u64;
96 }
97 }
98
99 #[must_use]
101 pub const fn tick_rate(&self) -> f32 {
102 self.tick_rate
103 }
104
105 #[must_use]
107 pub fn milliseconds_per_tick(&self) -> f32 {
108 self.nanoseconds_per_tick as f32 / NANOS_PER_MS as f32
109 }
110
111 pub const fn set_frozen(&mut self, frozen: bool) {
113 self.is_frozen = frozen;
114 }
115
116 #[must_use]
118 pub const fn is_frozen(&self) -> bool {
119 self.is_frozen
120 }
121
122 #[must_use]
124 pub const fn is_stepping_forward(&self) -> bool {
125 self.frozen_ticks_to_run > 0
126 }
127
128 #[must_use]
130 pub const fn frozen_ticks_to_run(&self) -> i32 {
131 self.frozen_ticks_to_run
132 }
133
134 #[must_use]
136 pub const fn runs_normally(&self) -> bool {
137 self.run_game_elements
138 }
139
140 pub const fn tick(&mut self) {
143 self.run_game_elements = !self.is_frozen || self.frozen_ticks_to_run > 0;
144 if self.frozen_ticks_to_run > 0 {
145 self.frozen_ticks_to_run -= 1;
146 }
147 }
148
149 pub const fn increment_tick_count(&mut self) {
151 self.tick_count += 1;
152 }
153
154 pub const fn step_game_if_paused(&mut self, ticks: i32) -> bool {
159 if !self.is_frozen {
160 return false;
161 }
162 self.frozen_ticks_to_run = ticks;
163 true
164 }
165
166 pub const fn stop_stepping(&mut self) -> bool {
169 if self.frozen_ticks_to_run > 0 {
170 self.frozen_ticks_to_run = 0;
171 true
172 } else {
173 false
174 }
175 }
176
177 #[must_use]
181 pub const fn is_sprinting(&self) -> bool {
182 self.scheduled_current_sprint_ticks > 0
183 }
184
185 pub fn request_game_to_sprint(&mut self, ticks: i32) -> bool {
188 let interrupted = self.remaining_sprint_ticks > 0;
189 self.sprint_time_spent = 0;
190 self.scheduled_current_sprint_ticks = i64::from(ticks);
191 self.remaining_sprint_ticks = i64::from(ticks);
192 self.previous_is_frozen = self.is_frozen;
193 self.set_frozen(false);
194 interrupted
195 }
196
197 pub fn stop_sprinting(&mut self) -> Option<SprintReport> {
200 if self.remaining_sprint_ticks > 0 {
201 Some(self.finish_tick_sprint())
202 } else {
203 None
204 }
205 }
206
207 pub fn check_should_sprint_this_tick(&mut self) -> (bool, Option<SprintReport>) {
211 if !self.run_game_elements {
212 return (false, None);
213 }
214 if self.remaining_sprint_ticks > 0 {
215 self.sprint_tick_start_time = Some(Instant::now());
216 self.remaining_sprint_ticks -= 1;
217 (true, None)
218 } else if self.scheduled_current_sprint_ticks > 0 {
219 (false, Some(self.finish_tick_sprint()))
221 } else {
222 (false, None)
223 }
224 }
225
226 pub fn end_tick_work(&mut self) {
229 if let Some(start) = self.sprint_tick_start_time.take() {
230 self.sprint_time_spent += start.elapsed().as_nanos() as i64;
231 }
232 }
233
234 fn finish_tick_sprint(&mut self) -> SprintReport {
236 let completed_ticks = self.scheduled_current_sprint_ticks - self.remaining_sprint_ticks;
237 let time_spent_ms = (self.sprint_time_spent.max(1) as f64) / NANOS_PER_MS;
238
239 #[expect(
240 clippy::cast_possible_truncation,
241 reason = "TPS fits well within i32 range"
242 )]
243 let ticks_per_second = (MS_PER_SEC * completed_ticks as f64 / time_spent_ms) as i32;
244 let ms_per_tick = if completed_ticks == 0 {
245 f64::from(self.milliseconds_per_tick())
246 } else {
247 time_spent_ms / completed_ticks as f64
248 };
249
250 self.scheduled_current_sprint_ticks = 0;
251 self.sprint_time_spent = 0;
252 self.remaining_sprint_ticks = 0;
253 self.set_frozen(self.previous_is_frozen);
254
255 SprintReport {
256 ticks_per_second,
257 ms_per_tick,
258 }
259 }
260
261 pub fn record_tick_time(&mut self, tick_time_nanos: u64) {
266 let tick_index = (self.tick_count as usize) % TICK_STATS_SPAN;
267
268 self.aggregated_tick_times_nanos -= self.tick_times_nanos[tick_index];
270 self.aggregated_tick_times_nanos += tick_time_nanos;
271 self.tick_times_nanos[tick_index] = tick_time_nanos;
272
273 let tick_time_ms = tick_time_nanos as f32 / NANOS_PER_MS as f32;
275 self.smoothed_tick_time_ms = self.smoothed_tick_time_ms * TICK_TIME_SMOOTHING
276 + tick_time_ms * (1.0 - TICK_TIME_SMOOTHING);
277 }
278
279 #[must_use]
281 pub fn get_average_tick_time_nanos(&self) -> u64 {
282 let sample_count = self.tick_count.min(TICK_STATS_SPAN as u64).max(1);
283 self.aggregated_tick_times_nanos / sample_count
284 }
285
286 #[must_use]
288 pub fn get_average_mspt(&self) -> f32 {
289 self.get_average_tick_time_nanos() as f32 / NANOS_PER_MS as f32
290 }
291
292 #[must_use]
294 pub const fn get_smoothed_mspt(&self) -> f32 {
295 self.smoothed_tick_time_ms
296 }
297
298 #[must_use]
301 pub fn get_tps(&self) -> f32 {
302 let mspt = self.get_average_mspt();
303 if mspt <= 0.0 {
304 return self.tick_rate;
305 }
306 (MS_PER_SEC as f32 / mspt).min(self.tick_rate)
308 }
309
310 #[must_use]
312 pub const fn get_tick_times_nanos(&self) -> [u64; TICK_STATS_SPAN] {
313 self.tick_times_nanos
314 }
315
316 #[must_use]
320 pub fn get_p50(&self) -> f32 {
321 self.get_percentile(50)
322 }
323
324 #[must_use]
326 pub fn get_p95(&self) -> f32 {
327 self.get_percentile(95)
328 }
329
330 #[must_use]
332 pub fn get_p99(&self) -> f32 {
333 self.get_percentile(99)
334 }
335
336 #[must_use]
338 pub fn get_sample_count(&self) -> usize {
339 (self.tick_count as usize).min(TICK_STATS_SPAN)
340 }
341
342 fn get_percentile(&self, percentile: u8) -> f32 {
344 let sample_count = self.get_sample_count();
345 if sample_count == 0 {
346 return 0.0;
347 }
348
349 let mut sorted = self.tick_times_nanos;
351 sorted[..sample_count].sort_unstable();
352
353 let idx = (sample_count * percentile as usize / 100).min(sample_count - 1);
354 sorted[idx] as f32 / NANOS_PER_MS as f32
355 }
356}
357
358impl Default for TickRateManager {
359 fn default() -> Self {
360 Self::new()
361 }
362}