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