Skip to main content

galeon_engine/
game_loop.rs

1// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
2
3use crate::schedule::Schedule;
4use crate::virtual_time::VirtualTime;
5use crate::world::World;
6
7/// Fixed-timestep configuration stored as a resource.
8///
9/// The game loop ticks the schedule at a fixed rate (default 10 Hz for RTS).
10/// A time accumulator ensures deterministic simulation: the same inputs produce
11/// the same outputs regardless of frame rate.
12pub struct FixedTimestep {
13    /// Seconds per tick (1.0 / tick_rate).
14    pub step: f64,
15    /// Accumulated time not yet consumed by ticks.
16    accumulator: f64,
17    /// Total number of ticks executed.
18    pub tick_count: u64,
19}
20
21impl FixedTimestep {
22    /// Minimum tick rate (1 Hz). Below this, use event-driven scheduling instead.
23    pub const MIN_HZ: f64 = 1.0;
24    /// Maximum tick rate (240 Hz). Beyond this, the per-tick budget is too small
25    /// for meaningful simulation work and risks death-spiraling the game loop.
26    pub const MAX_HZ: f64 = 240.0;
27
28    /// Create a new fixed timestep at the given tick rate (Hz).
29    pub fn new(tick_rate: f64) -> Self {
30        assert!(
31            (Self::MIN_HZ..=Self::MAX_HZ).contains(&tick_rate),
32            "tick rate {tick_rate} Hz out of range [{}, {}]",
33            Self::MIN_HZ,
34            Self::MAX_HZ,
35        );
36        Self {
37            step: 1.0 / tick_rate,
38            accumulator: 0.0,
39            tick_count: 0,
40        }
41    }
42
43    /// Create a 10 Hz timestep (default for RTS).
44    pub fn default_rts() -> Self {
45        Self::new(10.0)
46    }
47
48    /// 20 Hz — good for turn-like strategy with smooth interpolation.
49    pub fn strategy() -> Self {
50        Self::new(20.0)
51    }
52
53    /// 30 Hz — action games, third-person, adventure.
54    pub fn action() -> Self {
55        Self::new(30.0)
56    }
57
58    /// 60 Hz — platformers, FPS, fighting games.
59    pub fn fast() -> Self {
60        Self::new(60.0)
61    }
62
63    /// Returns the tick rate in Hz.
64    pub fn tick_rate(&self) -> f64 {
65        1.0 / self.step
66    }
67}
68
69/// Advance the simulation by `elapsed` seconds.
70///
71/// Accumulates time and runs the schedule once per fixed step. Returns the
72/// number of ticks executed this frame.
73///
74/// The `FixedTimestep` must be inserted as a resource on the world before
75/// calling this function.
76pub fn tick(world: &mut World, schedule: &mut Schedule, elapsed: f64) -> u32 {
77    // Compute virtual elapsed (pass-through if no VirtualTime resource).
78    let virtual_elapsed = if let Some(mut vt) = world.try_take_resource::<VirtualTime>() {
79        let ve = vt.effective_elapsed(elapsed);
80        vt.elapsed += ve;
81        world.insert_resource(vt);
82        ve
83    } else {
84        elapsed
85    };
86
87    // Remove the timestep resource temporarily to avoid borrow conflicts.
88    let mut ts = world.take_resource::<FixedTimestep>();
89    ts.accumulator += virtual_elapsed;
90
91    let mut ticks = 0u32;
92    while ts.accumulator >= ts.step {
93        ts.accumulator -= ts.step;
94        ts.tick_count += 1;
95        ticks += 1;
96
97        // Advance the change-detection tick so mutations during this
98        // schedule run get a fresh stamp.
99        world.advance_tick();
100
101        // Re-insert timestep so systems can read it during this tick.
102        world.insert_resource(FixedTimestep {
103            step: ts.step,
104            accumulator: ts.accumulator,
105            tick_count: ts.tick_count,
106        });
107        schedule.run(world);
108        // Take it back for the next iteration.
109        ts = world.take_resource::<FixedTimestep>();
110    }
111
112    // Put the timestep back with remaining accumulator.
113    world.insert_resource(ts);
114    ticks
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::component::Component;
121    use crate::system_param::{QueryMut, Res};
122    use crate::virtual_time::VirtualTime;
123
124    #[derive(Debug)]
125    struct TickCounter(u32);
126    impl Component for TickCounter {}
127
128    fn count_system(mut counters: QueryMut<'_, TickCounter>) {
129        for (_, counter) in counters.iter_mut() {
130            counter.0 += 1;
131        }
132    }
133
134    #[test]
135    fn genre_presets() {
136        let rts = FixedTimestep::default_rts();
137        assert!((rts.tick_rate() - 10.0).abs() < f64::EPSILON);
138
139        let strat = FixedTimestep::strategy();
140        assert!((strat.tick_rate() - 20.0).abs() < f64::EPSILON);
141
142        let act = FixedTimestep::action();
143        assert!((act.tick_rate() - 30.0).abs() < f64::EPSILON);
144
145        let fps = FixedTimestep::fast();
146        assert!((fps.tick_rate() - 60.0).abs() < f64::EPSILON);
147    }
148
149    #[test]
150    fn rejects_below_min_hz() {
151        let result = std::panic::catch_unwind(|| FixedTimestep::new(0.5));
152        assert!(result.is_err());
153    }
154
155    #[test]
156    fn rejects_above_max_hz() {
157        let result = std::panic::catch_unwind(|| FixedTimestep::new(500.0));
158        assert!(result.is_err());
159    }
160
161    #[test]
162    fn accepts_boundary_values() {
163        let low = FixedTimestep::new(FixedTimestep::MIN_HZ);
164        assert!((low.tick_rate() - 1.0).abs() < f64::EPSILON);
165
166        let high = FixedTimestep::new(FixedTimestep::MAX_HZ);
167        assert!((high.tick_rate() - 240.0).abs() < f64::EPSILON);
168    }
169
170    #[test]
171    fn fixed_timestep_creation() {
172        let ts = FixedTimestep::new(10.0);
173        assert!((ts.step - 0.1).abs() < f64::EPSILON);
174        assert_eq!(ts.tick_count, 0);
175        assert!((ts.tick_rate() - 10.0).abs() < f64::EPSILON);
176    }
177
178    #[test]
179    fn tick_runs_correct_number_of_times() {
180        let mut world = World::new();
181        world.insert_resource(FixedTimestep::new(10.0));
182        world.spawn((TickCounter(0),));
183
184        let mut schedule = Schedule::new();
185        schedule.add_system::<(QueryMut<'_, TickCounter>,)>("simulate", "count", count_system);
186
187        // 0.25 seconds at 10 Hz = 2 ticks (0.05s remainder)
188        let ticks = tick(&mut world, &mut schedule, 0.25);
189        assert_eq!(ticks, 2);
190
191        let counts: Vec<u32> = world.query::<&TickCounter>().map(|(_, c)| c.0).collect();
192        assert_eq!(counts, vec![2]);
193    }
194
195    #[test]
196    fn accumulator_carries_remainder() {
197        let mut world = World::new();
198        world.insert_resource(FixedTimestep::new(10.0));
199        world.spawn((TickCounter(0),));
200
201        let mut schedule = Schedule::new();
202        schedule.add_system::<(QueryMut<'_, TickCounter>,)>("simulate", "count", count_system);
203
204        // 0.05s — not enough for a tick
205        let ticks = tick(&mut world, &mut schedule, 0.05);
206        assert_eq!(ticks, 0);
207
208        // Another 0.06s — total 0.11s, enough for 1 tick (0.01s remainder)
209        let ticks = tick(&mut world, &mut schedule, 0.06);
210        assert_eq!(ticks, 1);
211
212        let counts: Vec<u32> = world.query::<&TickCounter>().map(|(_, c)| c.0).collect();
213        assert_eq!(counts, vec![1]);
214    }
215
216    #[test]
217    fn tick_count_increments() {
218        let mut world = World::new();
219        world.insert_resource(FixedTimestep::new(10.0));
220
221        let mut schedule = Schedule::new();
222
223        tick(&mut world, &mut schedule, 0.35); // 3 ticks
224        let ts = world.resource::<FixedTimestep>();
225        assert_eq!(ts.tick_count, 3);
226    }
227
228    #[test]
229    fn systems_can_read_timestep() {
230        fn read_step(ts: Res<'_, FixedTimestep>) {
231            assert!((ts.step - 0.1).abs() < f64::EPSILON);
232        }
233
234        let mut world = World::new();
235        world.insert_resource(FixedTimestep::new(10.0));
236
237        let mut schedule = Schedule::new();
238        schedule.add_system::<(Res<'_, FixedTimestep>,)>("simulate", "read_step", read_step);
239
240        tick(&mut world, &mut schedule, 0.1);
241    }
242
243    #[test]
244    fn no_virtual_time_unchanged_behavior() {
245        // Identical to existing tick_runs_correct_number_of_times
246        let mut world = World::new();
247        world.insert_resource(FixedTimestep::new(10.0));
248        world.spawn((TickCounter(0),));
249
250        let mut schedule = Schedule::new();
251        schedule.add_system::<(QueryMut<'_, TickCounter>,)>("simulate", "count", count_system);
252
253        let ticks = tick(&mut world, &mut schedule, 0.25);
254        assert_eq!(ticks, 2);
255    }
256
257    #[test]
258    fn virtual_time_paused_zero_ticks() {
259        let mut world = World::new();
260        world.insert_resource(FixedTimestep::new(10.0));
261        let mut vt = VirtualTime::new();
262        vt.paused = true;
263        world.insert_resource(vt);
264        world.spawn((TickCounter(0),));
265
266        let mut schedule = Schedule::new();
267        schedule.add_system::<(QueryMut<'_, TickCounter>,)>("simulate", "count", count_system);
268
269        let ticks = tick(&mut world, &mut schedule, 1.0);
270        assert_eq!(ticks, 0);
271
272        let counts: Vec<u32> = world.query::<&TickCounter>().map(|(_, c)| c.0).collect();
273        assert_eq!(counts, vec![0]);
274    }
275
276    #[test]
277    fn virtual_time_scale_doubles_ticks() {
278        let mut world = World::new();
279        world.insert_resource(FixedTimestep::new(10.0));
280        let mut vt = VirtualTime::new();
281        vt.scale = 2.0;
282        world.insert_resource(vt);
283        world.spawn((TickCounter(0),));
284
285        let mut schedule = Schedule::new();
286        schedule.add_system::<(QueryMut<'_, TickCounter>,)>("simulate", "count", count_system);
287
288        // 0.1s real at 2x scale = 0.2s virtual = 2 ticks at 10 Hz
289        let ticks = tick(&mut world, &mut schedule, 0.1);
290        assert_eq!(ticks, 2);
291    }
292
293    #[test]
294    fn virtual_time_max_delta_prevents_death_spiral() {
295        let mut world = World::new();
296        world.insert_resource(FixedTimestep::new(10.0));
297        world.insert_resource(VirtualTime::new()); // max_delta = 0.25
298        world.spawn((TickCounter(0),));
299
300        let mut schedule = Schedule::new();
301        schedule.add_system::<(QueryMut<'_, TickCounter>,)>("simulate", "count", count_system);
302
303        // 5.0s real, clamped to 0.25s virtual = 2 ticks (not 50!)
304        let ticks = tick(&mut world, &mut schedule, 5.0);
305        assert_eq!(ticks, 2);
306    }
307
308    #[test]
309    fn virtual_time_elapsed_accumulates() {
310        let mut world = World::new();
311        world.insert_resource(FixedTimestep::new(10.0));
312        world.insert_resource(VirtualTime::new());
313
314        let mut schedule = Schedule::new();
315
316        tick(&mut world, &mut schedule, 0.1);
317        tick(&mut world, &mut schedule, 0.15);
318
319        let vt = world.resource::<VirtualTime>();
320        assert!((vt.elapsed - 0.25).abs() < f64::EPSILON);
321    }
322}