Skip to main content

repame_sim/
lib.rs

1//! Headless sim: a `bevy_ecs` world stepped without a renderer.
2//!
3//! The game owns a [`Sim`], advances it with fixed-timestep [`Sim::step`],
4//! and pulls a snapshot out each frame for the viewport (see `repame-sprite`).
5//! Systems are plain functions registered on the schedule; no `bevy_app`,
6//! no window, no renderer. The sim stays portable to full Bevy later.
7
8use web_time::Duration;
9
10use bevy_ecs::prelude::*;
11use bevy_ecs::schedule::{IntoScheduleConfigs, Schedule};
12use bevy_ecs::system::ScheduleSystem;
13
14pub use bevy_ecs;
15
16/// Fixed-timestep simulation state.
17///
18/// The game owns a `Sim`, registers plain systems on its schedule, feeds it
19/// wall-clock time every frame with [`Sim::step`], and pulls a snapshot out
20/// for the viewport. There is no window, no renderer, and no app object.
21pub struct Sim {
22    /// Entity/component store. Game crates add their own components here.
23    pub world: World,
24    schedule: Schedule,
25    accumulator: Duration,
26    /// Fixed step size. Defaults to 60 Hz (see [`Sim::with_default_step`]).
27    ///
28    /// Each [`Sim::tick`] advances [`SimTime`] by exactly this amount, so
29    /// gameplay stays deterministic regardless of frame rate. Change it
30    /// before the first [`Sim::step`] call; changing it mid-run rescales
31    /// future ticks but leaves already-simulated time untouched.
32    pub step: Duration,
33    /// Maximum fixed steps per [`Sim::step`] call. Defaults to `8`.
34    ///
35    /// Guards against the spiral of death: after a long hitch (app resume,
36    /// debugger pause, slow device), the sim runs at most this many catch-up
37    /// ticks and drops the excess wall time instead of scheduling dozens of
38    /// ticks that make the next frame even longer. The dropped time is gone,
39    /// not carried over. The sim slows down instead of freezing.
40    ///
41    /// **Note:** clamped to a minimum of `1` per call, so the sim always
42    /// makes progress while wall time keeps arriving.
43    pub max_steps: u32,
44}
45
46/// Sim time resource, advanced once per fixed step.
47#[derive(Resource, Clone, Copy, Debug, Default)]
48pub struct SimTime {
49    /// Total simulated seconds.
50    pub elapsed_secs: f64,
51    /// Fixed delta of the current step.
52    pub delta_secs: f32,
53}
54
55impl Sim {
56    pub fn new(step: Duration) -> Self {
57        let mut world = World::new();
58        world.init_resource::<SimTime>();
59        Self {
60            world,
61            schedule: Schedule::default(),
62            accumulator: Duration::ZERO,
63            step,
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 guaranteed execution order.
80    ///
81    /// A bare `Schedule` does not preserve `add_system` insertion order for
82    /// systems with conflicting accesses, so multi-step pipelines (economy,
83    /// combat) must register as one `(a, b, c).chain()` tuple through here
84    /// instead of separate `add_system` calls.
85    pub fn add_chained_systems<M>(
86        &mut self,
87        systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
88    ) -> &mut Self {
89        self.schedule.add_systems(systems);
90        self
91    }
92
93    /// Advance the sim by `dt` wall time, running zero or more fixed steps.
94    /// Returns the number of steps run.
95    ///
96    /// Wall time accumulates across calls: a `dt` smaller than [`Sim::step`]
97    /// banks its fraction for later, and a large `dt` runs several ticks at
98    /// once. At most [`Sim::max_steps`] ticks run per call; any time still
99    /// banked beyond that is dropped (see [`Sim::max_steps`]).
100    ///
101    /// Typical frame pump:
102    ///
103    /// ```ignore
104    /// let steps = sim.step(frame_dt);
105    /// // `steps` ticks ran; build the viewport snapshot from `sim.world`.
106    /// ```
107    pub fn step(&mut self, dt: Duration) -> u32 {
108        if self.step.is_zero() {
109            return 0;
110        }
111        self.accumulator += dt;
112        let mut ran = 0;
113        while self.accumulator >= self.step && ran < self.max_steps.max(1) {
114            self.accumulator -= self.step;
115            self.tick();
116            ran += 1;
117        }
118        if self.accumulator >= self.step {
119            // On hitch: drop only *whole* extra steps, keep fraction for
120            // alpha continuity.
121            let step_ns = self.step.as_nanos();
122            if step_ns > 0 {
123                let acc_ns = self.accumulator.as_nanos() % step_ns;
124                self.accumulator = Duration::from_nanos(acc_ns as u64);
125            } else {
126                self.accumulator = Duration::ZERO;
127            }
128        }
129        ran
130    }
131
132    /// Unconsumed fractional time carried to the next frame.
133    ///
134    /// Always less than one [`Sim::step`] (a full step would have ticked).
135    /// Returns [`Duration::ZERO`] right after a step boundary; after a
136    /// hitch that hit the [`Sim::max_steps`] cap only the sub-step
137    /// fraction is kept (whole extra steps are dropped, alpha stays
138    /// continuous).
139    pub fn leftover(&self) -> Duration {
140        self.accumulator
141    }
142
143    /// Blend factor between the last ticked state and the next one, from
144    /// `0.0` (just ticked) to `1.0` (a full step banked, tick imminent).
145    ///
146    /// Computed as `accumulator / step`, clamped to `0..1`; returns `0.0`
147    /// for a degenerate (zero) step size. The sim itself never interpolates.
148    /// Snapshots read the last ticked state, but games that keep the
149    /// previous snapshot can blend toward the current one with this factor
150    /// for smooth rendering.
151    ///
152    /// **Note:** after a capped hitch (see [`Sim::max_steps`]) only whole
153    /// steps are dropped, so `alpha` keeps the leftover fraction.
154    pub fn alpha(&self) -> f32 {
155        let step = self.step.as_secs_f64();
156        if step <= 0.0 {
157            return 0.0;
158        }
159        (self.accumulator.as_secs_f64() / step).clamp(0.0, 1.0) as f32
160    }
161
162    /// Run the schedule exactly once, advancing sim time by one step.
163    /// Tick-model games (integer logic steps) drive this directly instead
164    /// of the wall-clock [`Sim::step`] accumulator.
165    pub fn tick(&mut self) {
166        {
167            let mut time = self.world.resource_mut::<SimTime>();
168            time.delta_secs = self.step.as_secs_f32();
169            time.elapsed_secs += self.step.as_secs_f64();
170        }
171        self.schedule.run(&mut self.world);
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn fixed_steps_accumulate() {
181        let mut sim = Sim::with_default_step();
182        let ran = sim.step(Duration::from_nanos(50_000_001));
183        assert_eq!(ran, 3);
184        let time = sim.world.resource::<SimTime>();
185        assert!((time.elapsed_secs - 3.0 / 60.0).abs() < 1e-9);
186    }
187
188    #[test]
189    fn zero_step_never_ticks() {
190        let mut sim = Sim::new(Duration::ZERO);
191        let ran = sim.step(Duration::from_secs(10));
192        assert_eq!(ran, 0);
193        let time = sim.world.resource::<SimTime>();
194        assert_eq!(time.elapsed_secs, 0.0);
195    }
196
197    #[test]
198    fn spiral_of_death_is_capped() {
199        let mut sim = Sim::with_default_step();
200        sim.max_steps = 4;
201        let ran = sim.step(Duration::from_secs(10));
202        assert_eq!(ran, 4);
203        assert!(sim.leftover() < sim.step, "keeps only fraction, got {:?}", sim.leftover());
204    }
205
206    #[test]
207    fn alpha_is_interpolation_fraction() {
208        let mut sim = Sim::new(Duration::from_millis(16));
209        sim.step(Duration::from_millis(8));
210        assert!((sim.alpha() - 0.5).abs() < 1e-6, "got {}", sim.alpha());
211        sim.step(Duration::from_millis(8));
212        assert!(sim.alpha() < 1e-6, "step boundary, got {}", sim.alpha());
213    }
214
215    #[test]
216    fn chained_systems_run_in_order() {
217        use std::sync::{Arc, Mutex};
218        let log: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
219        let (a_log, b_log, c_log) = (log.clone(), log.clone(), log.clone());
220        let mut sim = Sim::with_default_step();
221        sim.add_chained_systems(
222            (
223                move || a_log.lock().unwrap().push("a"),
224                move || b_log.lock().unwrap().push("b"),
225                move || c_log.lock().unwrap().push("c"),
226            )
227                .chain(),
228        );
229        sim.tick();
230        assert_eq!(*log.lock().unwrap(), vec!["a", "b", "c"]);
231    }
232}