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. [`tape`] is the harness that turns the claim
14//! into a check inside one binary (record one intent per tick, hash the world
15//! after each tick in a canonical order, replay, diff), and carrying a tape
16//! plus its hashes to another target is what makes that target's claim one
17//! comparison instead of an argument.
18
19use web_time::Duration;
20
21use bevy_ecs::prelude::*;
22use bevy_ecs::schedule::{IntoScheduleConfigs, Schedule};
23use bevy_ecs::system::ScheduleSystem;
24
25pub use bevy_ecs;
26
27pub mod hash;
28pub mod rng;
29pub mod state;
30pub mod tape;
31pub mod task;
32pub mod timer;
33
34pub use hash::Hash64;
35pub use rng::{Rng, SimRng, WorldSeed};
36pub use state::{NextState, State, States, in_state};
37pub use tape::{Divergence, Intent, Setup, Tape, VerifyError, WorldHasher};
38pub use task::{Task, TaskPool};
39pub use timer::{StepTimer, TimerMode};
40
41/// Fixed-step sim state. Game registers systems,
42/// feeds wall time with `step`, reads `world` for the snapshot.
43pub struct Sim {
44    /// Entity/component store. Game crates add their own components here.
45    pub world: World,
46    schedule: Schedule,
47    /// Per-state transition appliers, run before `schedule` each tick.
48    state_appliers: Vec<Box<dyn FnMut(&mut World) + Send + Sync>>,
49    accumulator: Duration,
50    /// Fixed step size. Default 60 Hz.
51    ///
52    /// Each tick adds this to `SimTime`. Set before first `step`.
53    pub step: Duration,
54    /// Sim speed multiplier. Zero or negative pauses the sim; a non-finite
55    /// value runs at normal speed.
56    pub time_scale: f32,
57    /// Max ticks per `step` call. Default `8`.
58    ///
59    /// Caps catch-up after hitches. Excess wall time is dropped.
60    /// Clamped to a minimum of `1` so the sim keeps moving.
61    pub max_steps: u32,
62}
63
64/// Sim time, advanced once per fixed step.
65#[derive(Resource, Clone, Copy, Debug, Default)]
66pub struct SimTime {
67    /// Total simulated seconds.
68    pub elapsed_secs: f64,
69    /// Fixed delta of the current step.
70    pub delta_secs: f32,
71}
72
73impl Sim {
74    pub fn new(step: Duration) -> Self {
75        let mut world = World::new();
76        world.init_resource::<SimTime>();
77        Self {
78            world,
79            schedule: Schedule::default(),
80            state_appliers: Vec::new(),
81            accumulator: Duration::ZERO,
82            step,
83            time_scale: 1.0,
84            max_steps: 8,
85        }
86    }
87
88    /// 60 Hz fixed-step sim.
89    pub fn with_default_step() -> Self {
90        Self::new(Duration::from_secs_f64(1.0 / 60.0))
91    }
92
93    /// Register a system into the per-step schedule.
94    pub fn add_system<M>(&mut self, system: impl IntoSystem<(), (), M>) -> &mut Self {
95        self.schedule.add_systems(system);
96        self
97    }
98
99    /// Register systems with a fixed execution order.
100    ///
101    /// Use one `(a, b, c).chain()` tuple for pipelines that need order.
102    pub fn add_chained_systems<M>(
103        &mut self,
104        systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
105    ) -> &mut Self {
106        self.schedule.add_systems(systems);
107        self
108    }
109
110    /// Register a state set with its initial value.
111    ///
112    /// Call once per state type, before `on_enter`/`on_exit`/`on_transition`.
113    /// Transitions are applied at the head of each step, before the main
114    /// schedule, so gameplay never observes a half-switched state.
115    pub fn add_state<S: States>(&mut self, initial: S) -> &mut Self {
116        self.world.insert_resource(State::<S>::new(initial));
117        self.world.insert_resource(NextState::<S>::new());
118        state::init_schedules::<S>(&mut self.world);
119        self.state_appliers.push(Box::new(state::apply::<S>));
120        self
121    }
122
123    /// Systems that run when `state` is entered.
124    ///
125    /// Panics if `add_state` was not called for this state set first.
126    pub fn on_enter<S, M>(
127        &mut self,
128        state: S,
129        systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
130    ) -> &mut Self
131    where
132        S: States,
133    {
134        state::add_enter(&mut self.world, state, systems);
135        self
136    }
137
138    /// Systems that run when `state` is exited.
139    pub fn on_exit<S, M>(
140        &mut self,
141        state: S,
142        systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
143    ) -> &mut Self
144    where
145        S: States,
146    {
147        state::add_exit(&mut self.world, state, systems);
148        self
149    }
150
151    /// Systems that run on the `exited -> entered` edge.
152    pub fn on_transition<S, M>(
153        &mut self,
154        exited: S,
155        entered: S,
156        systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
157    ) -> &mut Self
158    where
159        S: States,
160    {
161        state::add_transition(&mut self.world, exited, entered, systems);
162        self
163    }
164
165    /// Request a state change, equivalent to writing `NextState<S>` from a
166    /// system. Panics if the state set was not registered.
167    pub fn set_state<S: States>(&mut self, next: S) -> &mut Self {
168        self.world.resource_mut::<NextState<S>>().set(next);
169        self
170    }
171
172    /// The current value of a state set.
173    pub fn state<S: States>(&self) -> &S {
174        self.world.resource::<State<S>>().get()
175    }
176
177    /// Advance by `dt` wall time. Returns ticks run.
178    ///
179    /// Small `dt` banks for later. Large `dt` runs up to `max_steps`
180    /// ticks, then drops leftover whole steps. Example:
181    ///
182    /// ```ignore
183    /// let steps = sim.step(frame_dt);
184    /// ```
185    pub fn step(&mut self, dt: Duration) -> u32 {
186        self.step_with(dt, |_| {})
187    }
188
189    /// Whether the clock is stopped. Zero or negative pauses; a non-finite
190    /// value is a mistake, so it runs at normal speed instead of stalling.
191    fn paused(&self) -> bool {
192        self.time_scale.is_finite() && self.time_scale <= 0.0
193    }
194
195    pub fn step_with(&mut self, dt: Duration, mut after_tick: impl FnMut(&mut World)) -> u32 {
196        if self.step.is_zero() {
197            return 0;
198        }
199        if self.paused() {
200            // Drop the wall-clock time rather than bank it, so resuming after a
201            // long pause does not fire a burst of catch-up ticks.
202            self.accumulator = Duration::ZERO;
203            return 0;
204        }
205        self.accumulator += dt;
206        let mut ran = 0;
207        while self.accumulator >= self.step && ran < self.max_steps.max(1) {
208            self.accumulator -= self.step;
209            self.tick_with(&mut after_tick);
210            ran += 1;
211        }
212        if self.accumulator >= self.step {
213            // Hitch: keep only the sub-step fraction for alpha continuity.
214            let step_ns = self.step.as_nanos();
215            if step_ns > 0 {
216                let acc_ns = self.accumulator.as_nanos() % step_ns;
217                self.accumulator = Duration::from_nanos(acc_ns as u64);
218            } else {
219                self.accumulator = Duration::ZERO;
220            }
221        }
222        ran
223    }
224
225    /// Unconsumed time carried to the next frame. Below one step.
226    pub fn leftover(&self) -> Duration {
227        self.accumulator
228    }
229
230    /// Blend factor `accumulator / step`, clamped to `0..1`.
231    /// Zero step returns `0.0`. For render blending between snapshots: keep
232    /// the previous snapshot and lerp toward the current one by this alpha
233    /// (NT motion at 30 Hz sim on 144 Hz display needs it; without it every
234    /// fast mover judders on 4-5 identical frames then jumps).
235    pub fn alpha(&self) -> f32 {
236        let step = self.step.as_secs_f64();
237        if step <= 0.0 {
238            return 0.0;
239        }
240        (self.accumulator.as_secs_f64() / step).clamp(0.0, 1.0) as f32
241    }
242
243    /// Run the schedule once, advancing sim time by one step.
244    /// Tick-model games drive this directly instead of `step`.
245    pub fn tick(&mut self) {
246        self.tick_with(|_| {});
247    }
248
249    fn tick_with(&mut self, after_tick: impl FnOnce(&mut World)) {
250        if self.paused() {
251            return;
252        }
253        let time_scale = if self.time_scale.is_finite() {
254            self.time_scale
255        } else {
256            1.0
257        };
258        {
259            let mut time = self.world.resource_mut::<SimTime>();
260            time.delta_secs = self.step.as_secs_f32() * time_scale;
261            time.elapsed_secs += self.step.as_secs_f64() * time_scale as f64;
262        }
263        let Sim {
264            state_appliers,
265            world,
266            schedule,
267            ..
268        } = self;
269        for apply in state_appliers.iter_mut() {
270            apply(world);
271        }
272        schedule.run(world);
273        after_tick(world);
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn fixed_steps_accumulate() {
283        let mut sim = Sim::with_default_step();
284        let ran = sim.step(Duration::from_nanos(50_000_001));
285        assert_eq!(ran, 3);
286        let time = sim.world.resource::<SimTime>();
287        assert!((time.elapsed_secs - 3.0 / 60.0).abs() < 1e-9);
288    }
289
290    #[test]
291    fn zero_time_scale_pauses_without_banking_wall_time() {
292        let per_frame = Sim::with_default_step().step(Duration::from_millis(50));
293        assert!(per_frame > 0);
294
295        let mut sim = Sim::with_default_step();
296        sim.step(Duration::from_millis(50));
297        let before = sim.world.resource::<SimTime>().elapsed_secs;
298
299        sim.time_scale = 0.0;
300        assert_eq!(
301            sim.step(Duration::from_secs(5)),
302            0,
303            "paused sim runs no ticks"
304        );
305        sim.tick();
306        assert_eq!(
307            sim.world.resource::<SimTime>().elapsed_secs,
308            before,
309            "paused sim does not advance its clock"
310        );
311
312        sim.time_scale = 1.0;
313        assert_eq!(
314            sim.step(Duration::from_millis(50)),
315            per_frame,
316            "resuming must not fire a catch-up burst"
317        );
318    }
319
320    #[test]
321    fn non_finite_time_scale_runs_at_normal_speed() {
322        let mut sim = Sim::with_default_step();
323        sim.time_scale = f32::NAN;
324        sim.step(Duration::from_millis(51));
325        let time = sim.world.resource::<SimTime>();
326        assert!(
327            (time.delta_secs - 1.0 / 60.0).abs() < 1e-6,
328            "a non-finite scale must not stall the delta, got {}",
329            time.delta_secs
330        );
331        assert!(time.elapsed_secs > 0.0, "the clock still advances");
332    }
333
334    #[test]
335    fn post_tick_runs_after_each_fixed_step() {
336        use std::sync::{Arc, Mutex};
337
338        let log = Arc::new(Mutex::new(Vec::new()));
339        let sim_log = log.clone();
340        let mut sim = Sim::with_default_step();
341        sim.add_system(move || sim_log.lock().unwrap().push("sim"));
342        let post_log = log.clone();
343
344        let ran = sim.step_with(Duration::from_millis(51), move |_| {
345            post_log.lock().unwrap().push("post");
346        });
347
348        assert_eq!(ran, 3);
349        assert_eq!(
350            *log.lock().unwrap(),
351            ["sim", "post", "sim", "post", "sim", "post"]
352        );
353    }
354
355    #[test]
356    fn zero_step_never_ticks() {
357        let mut sim = Sim::new(Duration::ZERO);
358        let ran = sim.step(Duration::from_secs(10));
359        assert_eq!(ran, 0);
360        let time = sim.world.resource::<SimTime>();
361        assert_eq!(time.elapsed_secs, 0.0);
362    }
363
364    #[test]
365    fn spiral_of_death_is_capped() {
366        let mut sim = Sim::with_default_step();
367        sim.max_steps = 4;
368        let ran = sim.step(Duration::from_secs(10));
369        assert_eq!(ran, 4);
370        assert!(
371            sim.leftover() < sim.step,
372            "keeps only fraction, got {:?}",
373            sim.leftover()
374        );
375    }
376
377    #[test]
378    fn alpha_is_interpolation_fraction() {
379        let mut sim = Sim::new(Duration::from_millis(16));
380        sim.step(Duration::from_millis(8));
381        assert!((sim.alpha() - 0.5).abs() < 1e-6, "got {}", sim.alpha());
382        sim.step(Duration::from_millis(8));
383        assert!(sim.alpha() < 1e-6, "step boundary, got {}", sim.alpha());
384    }
385
386    #[test]
387    fn time_scale_changes_simulated_delta_without_changing_wall_accumulation() {
388        let mut sim = Sim::with_default_step();
389        sim.time_scale = 0.25;
390        let ran = sim.step(Duration::from_nanos(16_666_667));
391        let time = sim.world.resource::<SimTime>();
392        assert_eq!(ran, 1);
393        assert!((time.delta_secs - 1.0 / 240.0).abs() < 1e-6);
394        assert!((time.elapsed_secs - 1.0 / 240.0).abs() < 1e-9);
395    }
396
397    #[test]
398    fn chained_systems_run_in_order() {
399        use std::sync::{Arc, Mutex};
400        let log: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
401        let (a_log, b_log, c_log) = (log.clone(), log.clone(), log.clone());
402        let mut sim = Sim::with_default_step();
403        sim.add_chained_systems(
404            (
405                move || a_log.lock().unwrap().push("a"),
406                move || b_log.lock().unwrap().push("b"),
407                move || c_log.lock().unwrap().push("c"),
408            )
409                .chain(),
410        );
411        sim.tick();
412        assert_eq!(*log.lock().unwrap(), vec!["a", "b", "c"]);
413    }
414}