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    pub time_scale: f32,
36    /// Max ticks per `step` call. Default `8`.
37    ///
38    /// Caps catch-up after hitches. Excess wall time is dropped.
39    /// Clamped to a minimum of `1` so the sim keeps moving.
40    pub max_steps: u32,
41}
42
43/// Sim time, advanced once per fixed step.
44#[derive(Resource, Clone, Copy, Debug, Default)]
45pub struct SimTime {
46    /// Total simulated seconds.
47    pub elapsed_secs: f64,
48    /// Fixed delta of the current step.
49    pub delta_secs: f32,
50}
51
52impl Sim {
53    pub fn new(step: Duration) -> Self {
54        let mut world = World::new();
55        world.init_resource::<SimTime>();
56        Self {
57            world,
58            schedule: Schedule::default(),
59            accumulator: Duration::ZERO,
60            step,
61            time_scale: 1.0,
62            max_steps: 8,
63        }
64    }
65
66    /// 60 Hz fixed-step sim.
67    pub fn with_default_step() -> Self {
68        Self::new(Duration::from_secs_f64(1.0 / 60.0))
69    }
70
71    /// Register a system into the per-step schedule.
72    pub fn add_system<M>(&mut self, system: impl IntoSystem<(), (), M>) -> &mut Self {
73        self.schedule.add_systems(system);
74        self
75    }
76
77    /// Register systems with a fixed execution order.
78    ///
79    /// Use one `(a, b, c).chain()` tuple for pipelines that need order.
80    pub fn add_chained_systems<M>(
81        &mut self,
82        systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
83    ) -> &mut Self {
84        self.schedule.add_systems(systems);
85        self
86    }
87
88    /// Advance by `dt` wall time. Returns ticks run.
89    ///
90    /// Small `dt` banks for later. Large `dt` runs up to `max_steps`
91    /// ticks, then drops leftover whole steps. Example:
92    ///
93    /// ```ignore
94    /// let steps = sim.step(frame_dt);
95    /// ```
96    pub fn step(&mut self, dt: Duration) -> u32 {
97        self.step_with(dt, |_| {})
98    }
99
100    pub fn step_with(&mut self, dt: Duration, mut after_tick: impl FnMut(&mut World)) -> u32 {
101        if self.step.is_zero() {
102            return 0;
103        }
104        self.accumulator += dt;
105        let mut ran = 0;
106        while self.accumulator >= self.step && ran < self.max_steps.max(1) {
107            self.accumulator -= self.step;
108            self.tick_with(&mut after_tick);
109            ran += 1;
110        }
111        if self.accumulator >= self.step {
112            // Hitch: keep only the sub-step fraction for alpha continuity.
113            let step_ns = self.step.as_nanos();
114            if step_ns > 0 {
115                let acc_ns = self.accumulator.as_nanos() % step_ns;
116                self.accumulator = Duration::from_nanos(acc_ns as u64);
117            } else {
118                self.accumulator = Duration::ZERO;
119            }
120        }
121        ran
122    }
123
124    /// Unconsumed time carried to the next frame. Below one step.
125    pub fn leftover(&self) -> Duration {
126        self.accumulator
127    }
128
129    /// Blend factor `accumulator / step`, clamped to `0..1`.
130    /// Zero step returns `0.0`. For render blending between snapshots: keep
131    /// the previous snapshot and lerp toward the current one by this alpha
132    /// (NT motion at 30 Hz sim on 144 Hz display needs it; without it every
133    /// fast mover judders on 4-5 identical frames then jumps).
134    pub fn alpha(&self) -> f32 {
135        let step = self.step.as_secs_f64();
136        if step <= 0.0 {
137            return 0.0;
138        }
139        (self.accumulator.as_secs_f64() / step).clamp(0.0, 1.0) as f32
140    }
141
142    /// Run the schedule once, advancing sim time by one step.
143    /// Tick-model games drive this directly instead of `step`.
144    pub fn tick(&mut self) {
145        self.tick_with(|_| {});
146    }
147
148    fn tick_with(&mut self, after_tick: impl FnOnce(&mut World)) {
149        let time_scale = if self.time_scale.is_finite() && self.time_scale > 0.0 {
150            self.time_scale
151        } else {
152            1.0
153        };
154        {
155            let mut time = self.world.resource_mut::<SimTime>();
156            time.delta_secs = self.step.as_secs_f32() * time_scale;
157            time.elapsed_secs += self.step.as_secs_f64() * time_scale as f64;
158        }
159        self.schedule.run(&mut self.world);
160        after_tick(&mut self.world);
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn fixed_steps_accumulate() {
170        let mut sim = Sim::with_default_step();
171        let ran = sim.step(Duration::from_nanos(50_000_001));
172        assert_eq!(ran, 3);
173        let time = sim.world.resource::<SimTime>();
174        assert!((time.elapsed_secs - 3.0 / 60.0).abs() < 1e-9);
175    }
176
177    #[test]
178    fn post_tick_runs_after_each_fixed_step() {
179        use std::sync::{Arc, Mutex};
180
181        let log = Arc::new(Mutex::new(Vec::new()));
182        let sim_log = log.clone();
183        let mut sim = Sim::with_default_step();
184        sim.add_system(move || sim_log.lock().unwrap().push("sim"));
185        let post_log = log.clone();
186
187        let ran = sim.step_with(Duration::from_millis(51), move |_| {
188            post_log.lock().unwrap().push("post");
189        });
190
191        assert_eq!(ran, 3);
192        assert_eq!(
193            *log.lock().unwrap(),
194            ["sim", "post", "sim", "post", "sim", "post"]
195        );
196    }
197
198    #[test]
199    fn zero_step_never_ticks() {
200        let mut sim = Sim::new(Duration::ZERO);
201        let ran = sim.step(Duration::from_secs(10));
202        assert_eq!(ran, 0);
203        let time = sim.world.resource::<SimTime>();
204        assert_eq!(time.elapsed_secs, 0.0);
205    }
206
207    #[test]
208    fn spiral_of_death_is_capped() {
209        let mut sim = Sim::with_default_step();
210        sim.max_steps = 4;
211        let ran = sim.step(Duration::from_secs(10));
212        assert_eq!(ran, 4);
213        assert!(
214            sim.leftover() < sim.step,
215            "keeps only fraction, got {:?}",
216            sim.leftover()
217        );
218    }
219
220    #[test]
221    fn alpha_is_interpolation_fraction() {
222        let mut sim = Sim::new(Duration::from_millis(16));
223        sim.step(Duration::from_millis(8));
224        assert!((sim.alpha() - 0.5).abs() < 1e-6, "got {}", sim.alpha());
225        sim.step(Duration::from_millis(8));
226        assert!(sim.alpha() < 1e-6, "step boundary, got {}", sim.alpha());
227    }
228
229    #[test]
230    fn time_scale_changes_simulated_delta_without_changing_wall_accumulation() {
231        let mut sim = Sim::with_default_step();
232        sim.time_scale = 0.25;
233        let ran = sim.step(Duration::from_nanos(16_666_667));
234        let time = sim.world.resource::<SimTime>();
235        assert_eq!(ran, 1);
236        assert!((time.delta_secs - 1.0 / 240.0).abs() < 1e-6);
237        assert!((time.elapsed_secs - 1.0 / 240.0).abs() < 1e-9);
238    }
239
240    #[test]
241    fn chained_systems_run_in_order() {
242        use std::sync::{Arc, Mutex};
243        let log: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
244        let (a_log, b_log, c_log) = (log.clone(), log.clone(), log.clone());
245        let mut sim = Sim::with_default_step();
246        sim.add_chained_systems(
247            (
248                move || a_log.lock().unwrap().push("a"),
249                move || b_log.lock().unwrap().push("b"),
250                move || c_log.lock().unwrap().push("c"),
251            )
252                .chain(),
253        );
254        sim.tick();
255        assert_eq!(*log.lock().unwrap(), vec!["a", "b", "c"]);
256    }
257}