Skip to main content

repame_sim/
lib.rs

1//! Headless sim: `bevy_ecs` world stepped without a renderer.
2//! Game owns `Sim`, feeds wall time via `Sim::step`,
3//! then reads a snapshot for the viewport.
4//!
5//! Determinism stance: `bevy_ecs` query iteration follows entity allocation
6//! history, so a replay with an identical spawn history iterates
7//! identically. The sim does NOT sort systems' iteration for you: any
8//! gameplay outcome that depends on iteration order (damage application,
9//! projectile-vs-multiple-enemies resolution) must sort explicitly in the
10//! affected system, or replays diverge the moment allocation history does.
11//! Cross-platform float equality (notably `Perlin`/`sin`/`powf` in fx) is
12//! additionally *not* guaranteed between native libm and wasm; same-binary
13//! replay is the supported claim, cross-target equality needs a harness
14//! (run one trace twice, hash the world per tick, diff).
15
16use web_time::Duration;
17
18use bevy_ecs::prelude::*;
19use bevy_ecs::schedule::{IntoScheduleConfigs, Schedule};
20use bevy_ecs::system::ScheduleSystem;
21
22pub use bevy_ecs;
23
24/// Fixed-step sim state. Game registers systems,
25/// feeds wall time with `step`, reads `world` for the snapshot.
26pub struct Sim {
27    /// Entity/component store. Game crates add their own components here.
28    pub world: World,
29    schedule: Schedule,
30    accumulator: Duration,
31    /// Fixed step size. Default 60 Hz.
32    ///
33    /// Each tick adds this to `SimTime`. Set before first `step`.
34    pub step: Duration,
35    /// Sim speed multiplier. Zero or negative pauses the sim; a non-finite
36    /// value runs at normal speed.
37    pub time_scale: f32,
38    /// Max ticks per `step` call. Default `8`.
39    ///
40    /// Caps catch-up after hitches. Excess wall time is dropped.
41    /// Clamped to a minimum of `1` so the sim keeps moving.
42    pub max_steps: u32,
43}
44
45/// Sim time, advanced once per fixed step.
46#[derive(Resource, Clone, Copy, Debug, Default)]
47pub struct SimTime {
48    /// Total simulated seconds.
49    pub elapsed_secs: f64,
50    /// Fixed delta of the current step.
51    pub delta_secs: f32,
52}
53
54impl Sim {
55    pub fn new(step: Duration) -> Self {
56        let mut world = World::new();
57        world.init_resource::<SimTime>();
58        Self {
59            world,
60            schedule: Schedule::default(),
61            accumulator: Duration::ZERO,
62            step,
63            time_scale: 1.0,
64            max_steps: 8,
65        }
66    }
67
68    /// 60 Hz fixed-step sim.
69    pub fn with_default_step() -> Self {
70        Self::new(Duration::from_secs_f64(1.0 / 60.0))
71    }
72
73    /// Register a system into the per-step schedule.
74    pub fn add_system<M>(&mut self, system: impl IntoSystem<(), (), M>) -> &mut Self {
75        self.schedule.add_systems(system);
76        self
77    }
78
79    /// Register systems with a fixed execution order.
80    ///
81    /// Use one `(a, b, c).chain()` tuple for pipelines that need order.
82    pub fn add_chained_systems<M>(
83        &mut self,
84        systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
85    ) -> &mut Self {
86        self.schedule.add_systems(systems);
87        self
88    }
89
90    /// Advance by `dt` wall time. Returns ticks run.
91    ///
92    /// Small `dt` banks for later. Large `dt` runs up to `max_steps`
93    /// ticks, then drops leftover whole steps. Example:
94    ///
95    /// ```ignore
96    /// let steps = sim.step(frame_dt);
97    /// ```
98    pub fn step(&mut self, dt: Duration) -> u32 {
99        self.step_with(dt, |_| {})
100    }
101
102    /// Whether the clock is stopped. Zero or negative pauses; a non-finite
103    /// value is a mistake, so it runs at normal speed instead of stalling.
104    fn paused(&self) -> bool {
105        self.time_scale.is_finite() && self.time_scale <= 0.0
106    }
107
108    pub fn step_with(&mut self, dt: Duration, mut after_tick: impl FnMut(&mut World)) -> u32 {
109        if self.step.is_zero() {
110            return 0;
111        }
112        if self.paused() {
113            // Drop the wall-clock time rather than bank it, so resuming after a
114            // long pause does not fire a burst of catch-up ticks.
115            self.accumulator = Duration::ZERO;
116            return 0;
117        }
118        self.accumulator += dt;
119        let mut ran = 0;
120        while self.accumulator >= self.step && ran < self.max_steps.max(1) {
121            self.accumulator -= self.step;
122            self.tick_with(&mut after_tick);
123            ran += 1;
124        }
125        if self.accumulator >= self.step {
126            // Hitch: keep only the sub-step fraction for alpha continuity.
127            let step_ns = self.step.as_nanos();
128            if step_ns > 0 {
129                let acc_ns = self.accumulator.as_nanos() % step_ns;
130                self.accumulator = Duration::from_nanos(acc_ns as u64);
131            } else {
132                self.accumulator = Duration::ZERO;
133            }
134        }
135        ran
136    }
137
138    /// Unconsumed time carried to the next frame. Below one step.
139    pub fn leftover(&self) -> Duration {
140        self.accumulator
141    }
142
143    /// Blend factor `accumulator / step`, clamped to `0..1`.
144    /// Zero step returns `0.0`. For render blending between snapshots: keep
145    /// the previous snapshot and lerp toward the current one by this alpha
146    /// (NT motion at 30 Hz sim on 144 Hz display needs it; without it every
147    /// fast mover judders on 4-5 identical frames then jumps).
148    pub fn alpha(&self) -> f32 {
149        let step = self.step.as_secs_f64();
150        if step <= 0.0 {
151            return 0.0;
152        }
153        (self.accumulator.as_secs_f64() / step).clamp(0.0, 1.0) as f32
154    }
155
156    /// Run the schedule once, advancing sim time by one step.
157    /// Tick-model games drive this directly instead of `step`.
158    pub fn tick(&mut self) {
159        self.tick_with(|_| {});
160    }
161
162    fn tick_with(&mut self, after_tick: impl FnOnce(&mut World)) {
163        if self.paused() {
164            return;
165        }
166        let time_scale = if self.time_scale.is_finite() {
167            self.time_scale
168        } else {
169            1.0
170        };
171        {
172            let mut time = self.world.resource_mut::<SimTime>();
173            time.delta_secs = self.step.as_secs_f32() * time_scale;
174            time.elapsed_secs += self.step.as_secs_f64() * time_scale as f64;
175        }
176        self.schedule.run(&mut self.world);
177        after_tick(&mut self.world);
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn fixed_steps_accumulate() {
187        let mut sim = Sim::with_default_step();
188        let ran = sim.step(Duration::from_nanos(50_000_001));
189        assert_eq!(ran, 3);
190        let time = sim.world.resource::<SimTime>();
191        assert!((time.elapsed_secs - 3.0 / 60.0).abs() < 1e-9);
192    }
193
194    #[test]
195    fn zero_time_scale_pauses_without_banking_wall_time() {
196        let per_frame = Sim::with_default_step().step(Duration::from_millis(50));
197        assert!(per_frame > 0);
198
199        let mut sim = Sim::with_default_step();
200        sim.step(Duration::from_millis(50));
201        let before = sim.world.resource::<SimTime>().elapsed_secs;
202
203        sim.time_scale = 0.0;
204        assert_eq!(
205            sim.step(Duration::from_secs(5)),
206            0,
207            "paused sim runs no ticks"
208        );
209        sim.tick();
210        assert_eq!(
211            sim.world.resource::<SimTime>().elapsed_secs,
212            before,
213            "paused sim does not advance its clock"
214        );
215
216        sim.time_scale = 1.0;
217        assert_eq!(
218            sim.step(Duration::from_millis(50)),
219            per_frame,
220            "resuming must not fire a catch-up burst"
221        );
222    }
223
224    #[test]
225    fn non_finite_time_scale_runs_at_normal_speed() {
226        let mut sim = Sim::with_default_step();
227        sim.time_scale = f32::NAN;
228        sim.step(Duration::from_millis(51));
229        let time = sim.world.resource::<SimTime>();
230        assert!(
231            (time.delta_secs - 1.0 / 60.0).abs() < 1e-6,
232            "a non-finite scale must not stall the delta, got {}",
233            time.delta_secs
234        );
235        assert!(time.elapsed_secs > 0.0, "the clock still advances");
236    }
237
238    #[test]
239    fn post_tick_runs_after_each_fixed_step() {
240        use std::sync::{Arc, Mutex};
241
242        let log = Arc::new(Mutex::new(Vec::new()));
243        let sim_log = log.clone();
244        let mut sim = Sim::with_default_step();
245        sim.add_system(move || sim_log.lock().unwrap().push("sim"));
246        let post_log = log.clone();
247
248        let ran = sim.step_with(Duration::from_millis(51), move |_| {
249            post_log.lock().unwrap().push("post");
250        });
251
252        assert_eq!(ran, 3);
253        assert_eq!(
254            *log.lock().unwrap(),
255            ["sim", "post", "sim", "post", "sim", "post"]
256        );
257    }
258
259    #[test]
260    fn zero_step_never_ticks() {
261        let mut sim = Sim::new(Duration::ZERO);
262        let ran = sim.step(Duration::from_secs(10));
263        assert_eq!(ran, 0);
264        let time = sim.world.resource::<SimTime>();
265        assert_eq!(time.elapsed_secs, 0.0);
266    }
267
268    #[test]
269    fn spiral_of_death_is_capped() {
270        let mut sim = Sim::with_default_step();
271        sim.max_steps = 4;
272        let ran = sim.step(Duration::from_secs(10));
273        assert_eq!(ran, 4);
274        assert!(
275            sim.leftover() < sim.step,
276            "keeps only fraction, got {:?}",
277            sim.leftover()
278        );
279    }
280
281    #[test]
282    fn alpha_is_interpolation_fraction() {
283        let mut sim = Sim::new(Duration::from_millis(16));
284        sim.step(Duration::from_millis(8));
285        assert!((sim.alpha() - 0.5).abs() < 1e-6, "got {}", sim.alpha());
286        sim.step(Duration::from_millis(8));
287        assert!(sim.alpha() < 1e-6, "step boundary, got {}", sim.alpha());
288    }
289
290    #[test]
291    fn time_scale_changes_simulated_delta_without_changing_wall_accumulation() {
292        let mut sim = Sim::with_default_step();
293        sim.time_scale = 0.25;
294        let ran = sim.step(Duration::from_nanos(16_666_667));
295        let time = sim.world.resource::<SimTime>();
296        assert_eq!(ran, 1);
297        assert!((time.delta_secs - 1.0 / 240.0).abs() < 1e-6);
298        assert!((time.elapsed_secs - 1.0 / 240.0).abs() < 1e-9);
299    }
300
301    #[test]
302    fn chained_systems_run_in_order() {
303        use std::sync::{Arc, Mutex};
304        let log: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
305        let (a_log, b_log, c_log) = (log.clone(), log.clone(), log.clone());
306        let mut sim = Sim::with_default_step();
307        sim.add_chained_systems(
308            (
309                move || a_log.lock().unwrap().push("a"),
310                move || b_log.lock().unwrap().push("b"),
311                move || c_log.lock().unwrap().push("c"),
312            )
313                .chain(),
314        );
315        sim.tick();
316        assert_eq!(*log.lock().unwrap(), vec!["a", "b", "c"]);
317    }
318}