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 self.accumulator = Duration::ZERO;
120 }
121 ran
122 }
123
124 /// Unconsumed fractional time carried to the next frame.
125 ///
126 /// Always less than one [`Sim::step`] (a full step would have ticked).
127 /// Returns [`Duration::ZERO`] right after a step boundary and after a
128 /// hitch that hit the [`Sim::max_steps`] cap (the excess is dropped,
129 /// not reported here).
130 pub fn leftover(&self) -> Duration {
131 self.accumulator
132 }
133
134 /// Blend factor between the last ticked state and the next one, from
135 /// `0.0` (just ticked) to `1.0` (a full step banked, tick imminent).
136 ///
137 /// Computed as `accumulator / step`, clamped to `0..1`; returns `0.0`
138 /// for a degenerate (zero) step size. The sim itself never interpolates.
139 /// Snapshots read the last ticked state, but games that keep the
140 /// previous snapshot can blend toward the current one with this factor
141 /// for smooth rendering.
142 ///
143 /// **Note:** after a capped hitch (see [`Sim::max_steps`]) the
144 /// accumulator is cleared, so `alpha` reads `0.0` on the next frame.
145 pub fn alpha(&self) -> f32 {
146 let step = self.step.as_secs_f64();
147 if step <= 0.0 {
148 return 0.0;
149 }
150 (self.accumulator.as_secs_f64() / step).clamp(0.0, 1.0) as f32
151 }
152
153 /// Run the schedule exactly once, advancing sim time by one step.
154 /// Tick-model games (integer logic steps) drive this directly instead
155 /// of the wall-clock [`Sim::step`] accumulator.
156 pub fn tick(&mut self) {
157 {
158 let mut time = self.world.resource_mut::<SimTime>();
159 time.delta_secs = self.step.as_secs_f32();
160 time.elapsed_secs += self.step.as_secs_f64();
161 }
162 self.schedule.run(&mut self.world);
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn fixed_steps_accumulate() {
172 let mut sim = Sim::with_default_step();
173 let ran = sim.step(Duration::from_nanos(50_000_001));
174 assert_eq!(ran, 3);
175 let time = sim.world.resource::<SimTime>();
176 assert!((time.elapsed_secs - 3.0 / 60.0).abs() < 1e-9);
177 }
178
179 #[test]
180 fn zero_step_never_ticks() {
181 let mut sim = Sim::new(Duration::ZERO);
182 let ran = sim.step(Duration::from_secs(10));
183 assert_eq!(ran, 0);
184 let time = sim.world.resource::<SimTime>();
185 assert_eq!(time.elapsed_secs, 0.0);
186 }
187
188 #[test]
189 fn spiral_of_death_is_capped() {
190 let mut sim = Sim::with_default_step();
191 sim.max_steps = 4;
192 let ran = sim.step(Duration::from_secs(10));
193 assert_eq!(ran, 4);
194 assert_eq!(sim.leftover(), Duration::ZERO);
195 }
196
197 #[test]
198 fn alpha_is_interpolation_fraction() {
199 let mut sim = Sim::new(Duration::from_millis(16));
200 sim.step(Duration::from_millis(8));
201 assert!((sim.alpha() - 0.5).abs() < 1e-6, "got {}", sim.alpha());
202 sim.step(Duration::from_millis(8));
203 assert!(sim.alpha() < 1e-6, "step boundary, got {}", sim.alpha());
204 }
205}