Skip to main content

continuous_game_loop/
continuous_game_loop.rs

1//! Reference host loop for continuously rendered, proportional-time games.
2//!
3//! The host owns wall time, game speed, the sub-minute accumulator, and all
4//! presentation state. Canwu only receives representable simulation durations
5//! and remains the authoritative source of world state and events.
6
7use canwu_api::{
8    ArmyId, Canwu, CanwuError, Command, CommandEnvelope, CommandRecord, CommandRequest,
9    CommandRequestId, DemoIds, Issuer, SimDuration, SimEvent, SimTime, TerritoryId, TransitState,
10    WorldSnapshot,
11};
12use std::{
13    fmt::{Display, Formatter},
14    time::Duration,
15};
16
17const SIMULATION_QUANTUM: SimDuration = SimDuration::minutes(1);
18const SIMULATION_MINUTE_NANOS: u128 = 60_000_000_000;
19
20// This game's host policy says that 1x advances the calendar by one simulation
21// minute per wall second. Canwu does not prescribe this rate. After converting
22// wall time into this base game-time unit, the proportional rule is simply:
23//
24//     simulation_elapsed = converted_wall_elapsed * game_speed
25const BASE_SIM_SECONDS_PER_WALL_SECOND: u128 = 60;
26
27const SIXTY_FPS_ISH: FrameProfile = FrameProfile {
28    name: "60 FPS-ish",
29    frame_millis: &[16, 17],
30};
31const THIRTY_FPS_ISH: FrameProfile = FrameProfile {
32    name: "30 FPS-ish",
33    frame_millis: &[33, 34],
34};
35
36// The scripted wall-time schedule is shared by both FPS runs. Input is captured
37// halfway through the first simulation minute and quantized upward to minute 1.
38// The 5x phase reaches about 25% visual progress with a half-minute remainder,
39// pause preserves that remainder, and 20x carries the army through arrival.
40const SPEED_SCHEDULE: [SpeedPhase; 5] = [
41    SpeedPhase::new(GameSpeed::X1, 500),
42    SpeedPhase::new(GameSpeed::X1, 500),
43    SpeedPhase::new(GameSpeed::X5, 53_900),
44    SpeedPhase::new(GameSpeed::Paused, 1_000),
45    SpeedPhase::new(GameSpeed::X20, 40_575),
46];
47
48#[derive(Clone, Copy, Debug)]
49enum GameSpeed {
50    Paused,
51    X1,
52    X5,
53    X20,
54}
55
56impl GameSpeed {
57    const fn multiplier(self) -> u128 {
58        match self {
59            Self::Paused => 0,
60            Self::X1 => 1,
61            Self::X5 => 5,
62            Self::X20 => 20,
63        }
64    }
65
66    const fn label(self) -> &'static str {
67        match self {
68            Self::Paused => "Paused",
69            Self::X1 => "1x",
70            Self::X5 => "5x",
71            Self::X20 => "20x",
72        }
73    }
74}
75
76#[derive(Clone, Copy)]
77struct SpeedPhase {
78    speed: GameSpeed,
79    wall_millis: u64,
80}
81
82impl SpeedPhase {
83    const fn new(speed: GameSpeed, wall_millis: u64) -> Self {
84        Self { speed, wall_millis }
85    }
86}
87
88#[derive(Clone, Copy)]
89struct FrameProfile {
90    name: &'static str,
91    frame_millis: &'static [u64],
92}
93
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95struct PresentationTime {
96    canonical: SimTime,
97    sub_minute: Duration,
98}
99
100impl Display for PresentationTime {
101    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
102        write!(
103            formatter,
104            "{} + {:.3}m",
105            self.canonical,
106            self.sub_minute.as_secs_f64() / 60.0,
107        )
108    }
109}
110
111/// Renderer-owned state. It is refreshed from detached public snapshots and is
112/// never written back into Canwu.
113struct PresentationState {
114    authoritative_location: TerritoryId,
115    last_authoritative_transit: Option<TransitState>,
116    visual_progress: Option<f64>,
117}
118
119impl PresentationState {
120    fn from_world(world: &WorldSnapshot, army: ArmyId) -> Self {
121        let army = world.army(army).expect("the demo army must exist");
122        Self {
123            authoritative_location: army.location,
124            last_authoritative_transit: army.transit.clone(),
125            visual_progress: None,
126        }
127    }
128
129    fn refresh_from_authority(&mut self, world: &WorldSnapshot, army: ArmyId) {
130        let army = world.army(army).expect("the demo army must exist");
131        self.authoritative_location = army.location;
132        if let Some(transit) = &army.transit {
133            self.last_authoritative_transit = Some(transit.clone());
134        }
135    }
136
137    fn render(&mut self, presentation_time: PresentationTime) -> Option<f64> {
138        self.visual_progress = self.last_authoritative_transit.as_ref().map(|transit| {
139            if presentation_time.canonical < transit.departed_at {
140                return 0.0;
141            }
142            let elapsed = std_duration(presentation_time.canonical - transit.departed_at)
143                .checked_add(presentation_time.sub_minute)
144                .expect("presentation elapsed time must remain representable");
145            let travel = std_duration(transit.arrives_at - transit.departed_at);
146            (elapsed.as_secs_f64() / travel.as_secs_f64()).clamp(0.0, 1.0)
147        });
148        self.visual_progress
149    }
150
151    fn describe(&self) -> String {
152        match (&self.last_authoritative_transit, self.visual_progress) {
153            (Some(transit), Some(progress)) => format!(
154                "army visual progress={:.1}% ({} -> {}, authoritative_location={})",
155                progress * 100.0,
156                transit.from,
157                transit.to,
158                self.authoritative_location,
159            ),
160            _ => format!("army stationary at {}", self.authoritative_location),
161        }
162    }
163}
164
165/// The authoritative simulation clock and the render clock deliberately live
166/// in different fields. Only whole simulation minutes cross the Canwu API.
167struct GameHost {
168    authority: Canwu,
169    ids: DemoIds,
170    presentation: PresentationState,
171    accumulated_sim_nanos: u128,
172    wall_elapsed: Duration,
173    frame: u64,
174    reported_events: usize,
175    next_progress_milestone: u8,
176    canonical_boundaries: usize,
177    verbose: bool,
178}
179
180impl GameHost {
181    fn new(verbose: bool) -> Result<Self, CanwuError> {
182        let authority = Canwu::demo(35)?;
183        let ids = Canwu::demo_ids();
184        let presentation = PresentationState::from_world(&authority.world(), ids.army);
185        Ok(Self {
186            authority,
187            ids,
188            presentation,
189            accumulated_sim_nanos: 0,
190            wall_elapsed: Duration::ZERO,
191            frame: 0,
192            reported_events: 0,
193            next_progress_milestone: 25,
194            canonical_boundaries: 0,
195            verbose,
196        })
197    }
198
199    fn run_phase(&mut self, phase: SpeedPhase, profile: FrameProfile) -> Result<(), CanwuError> {
200        let mut remaining_millis = phase.wall_millis;
201        let mut pattern_index = 0;
202        let mut first_frame = true;
203
204        while remaining_millis > 0 {
205            let frame_millis = profile.frame_millis[pattern_index % profile.frame_millis.len()]
206                .min(remaining_millis);
207            remaining_millis -= frame_millis;
208            self.render_frame(
209                Duration::from_millis(frame_millis),
210                phase.speed,
211                first_frame || remaining_millis == 0,
212            )?;
213            first_frame = false;
214            pattern_index += 1;
215        }
216        Ok(())
217    }
218
219    fn render_frame(
220        &mut self,
221        wall_dt: Duration,
222        speed: GameSpeed,
223        phase_edge: bool,
224    ) -> Result<(), CanwuError> {
225        self.frame += 1;
226        self.wall_elapsed += wall_dt;
227
228        let converted_wall_nanos = wall_dt.as_nanos() * BASE_SIM_SECONDS_PER_WALL_SECOND;
229        self.accumulated_sim_nanos += converted_wall_nanos * speed.multiplier();
230
231        let mut authority_changed = false;
232        while self.accumulated_sim_nanos >= SIMULATION_MINUTE_NANOS {
233            let receipts = self.authority.advance_canonical(SIMULATION_QUANTUM)?;
234            self.canonical_boundaries += receipts.len();
235            authority_changed |= !receipts.is_empty();
236            self.accumulated_sim_nanos -= SIMULATION_MINUTE_NANOS;
237        }
238
239        if authority_changed {
240            self.refresh_presentation_from_authority();
241        }
242
243        let progress = self.presentation.render(self.presentation_time());
244        let crossed_milestone = self.crossed_progress_milestone(progress);
245        let has_new_events = self.reported_events < self.authority.events().len();
246
247        if self.verbose && (phase_edge || crossed_milestone || has_new_events) {
248            self.print_frame(speed);
249        }
250        self.report_new_events();
251        Ok(())
252    }
253
254    /// This host never backdates mid-quantum input. It deterministically rounds
255    /// up to the next representable Canwu minute; another host may choose a
256    /// different policy, but it must apply the same policy across render FPS.
257    fn submit_player_command(&mut self) -> Result<(), CanwuError> {
258        let captured_at = self.presentation_time();
259        let due_at = if self.accumulated_sim_nanos == 0 {
260            self.authority.time()
261        } else {
262            self.authority
263                .time()
264                .checked_add(SIMULATION_QUANTUM)
265                .expect("the scripted command time must remain representable")
266        };
267        let envelope = CommandEnvelope::new(
268            Issuer::Actor(self.ids.commander),
269            Command::MoveArmy {
270                army: self.ids.army,
271                destination: self.ids.eastern_territory,
272            },
273        )
274        .at_time(due_at);
275        let request = CommandRequest::new(
276            CommandRequestId::new(1),
277            self.authority.revision(),
278            envelope,
279        );
280        let ingress = self.authority.enqueue_command(due_at, 0, request)?;
281
282        if self.verbose {
283            println!(
284                "INPUT: queued commander move as {:?}, captured_at={captured_at}, quantized_due_at={due_at}",
285                ingress.ingress_id,
286            );
287        }
288
289        // This drains ingress due at the current canonical time only. The
290        // mid-quantum command remains queued until normal accumulation reaches
291        // its quantized minute; future scheduled work is never jumped to.
292        let receipts = self.authority.advance_canonical(SimDuration::ZERO)?;
293        self.canonical_boundaries += receipts.len();
294        self.refresh_presentation_from_authority();
295        self.presentation.render(self.presentation_time());
296        if self.verbose {
297            self.print_frame(GameSpeed::X1);
298        }
299        self.report_new_events();
300        Ok(())
301    }
302
303    fn refresh_presentation_from_authority(&mut self) {
304        self.presentation
305            .refresh_from_authority(&self.authority.world(), self.ids.army);
306    }
307
308    fn presentation_time(&self) -> PresentationTime {
309        PresentationTime {
310            canonical: self.authority.time(),
311            sub_minute: Duration::from_nanos(
312                u64::try_from(self.accumulated_sim_nanos)
313                    .expect("the sub-minute accumulator must fit a Duration"),
314            ),
315        }
316    }
317
318    fn crossed_progress_milestone(&mut self, progress: Option<f64>) -> bool {
319        let Some(progress) = progress else {
320            return false;
321        };
322        let mut crossed = false;
323        while self.next_progress_milestone <= 100
324            && progress * 100.0 + f64::EPSILON >= f64::from(self.next_progress_milestone)
325        {
326            crossed = true;
327            self.next_progress_milestone += 25;
328        }
329        crossed
330    }
331
332    fn print_frame(&self, speed: GameSpeed) {
333        println!(
334            "frame={:>5} speed={:<6} wall={:>7.3}s visual_time={} canwu_time={} {}",
335            self.frame,
336            speed.label(),
337            self.wall_elapsed.as_secs_f64(),
338            self.presentation_time(),
339            self.authority.time(),
340            self.presentation.describe(),
341        );
342    }
343
344    fn report_new_events(&mut self) {
345        let events = self.authority.events();
346        if self.verbose {
347            for event in &events[self.reported_events..] {
348                println!(
349                    "EVENT: at={} type={} {}",
350                    event.timestamp,
351                    event.kind.qualified_event_type(),
352                    event.summary,
353                );
354            }
355        }
356        self.reported_events = events.len();
357    }
358
359    fn outcome(self, profile: FrameProfile) -> Result<RunOutcome, CanwuError> {
360        Ok(RunOutcome {
361            profile: profile.name,
362            frames: self.frame,
363            time: self.authority.time(),
364            world: self.authority.world(),
365            events: self.authority.events().to_vec(),
366            commands: self.authority.commands().to_vec(),
367            authoritative_state_hash: self.authority.authoritative_state_hash()?,
368            checkpoint_hash: self.authority.checkpoint_hash().to_owned(),
369            canonical_boundaries: self.canonical_boundaries,
370        })
371    }
372}
373
374struct RunOutcome {
375    profile: &'static str,
376    frames: u64,
377    time: SimTime,
378    world: WorldSnapshot,
379    events: Vec<SimEvent>,
380    commands: Vec<CommandRecord>,
381    authoritative_state_hash: String,
382    checkpoint_hash: String,
383    canonical_boundaries: usize,
384}
385
386fn run(profile: FrameProfile, verbose: bool) -> Result<RunOutcome, CanwuError> {
387    let mut host = GameHost::new(verbose)?;
388    if verbose {
389        println!("\n{} render loop", profile.name);
390    }
391
392    host.run_phase(SPEED_SCHEDULE[0], profile)?;
393    host.submit_player_command()?;
394    host.run_phase(SPEED_SCHEDULE[1], profile)?;
395    host.run_phase(SPEED_SCHEDULE[2], profile)?;
396
397    let time_before_pause = host.authority.time();
398    let remainder_before_pause = host.accumulated_sim_nanos;
399    let presentation_before_pause = host.presentation_time();
400    let frames_before_pause = host.frame;
401    assert!(
402        remainder_before_pause > 0,
403        "the pause fixture must preserve a fractional simulation minute"
404    );
405    host.run_phase(SPEED_SCHEDULE[3], profile)?;
406    assert!(
407        host.frame > frames_before_pause,
408        "pause must keep rendering"
409    );
410    assert_eq!(
411        host.authority.time(),
412        time_before_pause,
413        "pause must not advance Canwu"
414    );
415    assert_eq!(
416        host.accumulated_sim_nanos, remainder_before_pause,
417        "pause must not add desired simulation time"
418    );
419    assert_eq!(
420        host.presentation_time(),
421        presentation_before_pause,
422        "pause must freeze presentation time"
423    );
424
425    host.run_phase(SPEED_SCHEDULE[4], profile)?;
426    assert_eq!(
427        host.accumulated_sim_nanos, 0,
428        "the scripted schedule should end on a simulation-minute boundary"
429    );
430    host.outcome(profile)
431}
432
433fn std_duration(duration: SimDuration) -> Duration {
434    let minutes = u64::try_from(duration.as_minutes())
435        .expect("presentation interpolation requires a non-negative duration");
436    Duration::from_secs(
437        minutes
438            .checked_mul(60)
439            .expect("presentation duration must remain representable"),
440    )
441}
442
443fn assert_fps_independent(left: &RunOutcome, right: &RunOutcome) {
444    assert_ne!(
445        left.frames, right.frames,
446        "the render segmentations must differ"
447    );
448    assert_eq!(left.time, right.time, "Canwu time changed with render FPS");
449    assert_eq!(
450        left.world, right.world,
451        "world state changed with render FPS"
452    );
453    assert_eq!(left.events, right.events, "events changed with render FPS");
454    assert_eq!(
455        left.commands, right.commands,
456        "command ordering changed with render FPS"
457    );
458    assert_eq!(
459        left.authoritative_state_hash, right.authoritative_state_hash,
460        "authoritative state hash changed with render FPS"
461    );
462    assert_eq!(
463        left.checkpoint_hash, right.checkpoint_hash,
464        "checkpoint hash changed with render FPS"
465    );
466    assert_eq!(
467        left.canonical_boundaries, right.canonical_boundaries,
468        "canonical boundary count changed with render FPS"
469    );
470}
471
472fn assert_expected_outcome(outcome: &RunOutcome) {
473    let ids = Canwu::demo_ids();
474    let command_at = SimTime::from_minutes(1);
475    let arrival_at = SimTime::from_minutes(1_081);
476    let final_time = SimTime::from_minutes(1_082);
477
478    assert_eq!(outcome.time, final_time, "the scripted final time changed");
479    let army = outcome
480        .world
481        .army(ids.army)
482        .expect("the demo army must remain present");
483    assert_eq!(
484        army.location, ids.eastern_territory,
485        "the scheduled arrival must execute"
486    );
487    assert!(
488        army.transit.is_none(),
489        "the army must no longer be in authoritative transit"
490    );
491
492    assert_eq!(
493        outcome.commands.len(),
494        1,
495        "the move command must be accepted"
496    );
497    let command = &outcome.commands[0];
498    assert_eq!(command.accepted_at, command_at);
499    assert_eq!(command.envelope.expected_time, Some(command_at));
500    assert!(matches!(
501        &command.envelope.command,
502        Command::MoveArmy { army, destination }
503            if *army == ids.army && *destination == ids.eastern_territory
504    ));
505
506    let event_timeline = outcome
507        .events
508        .iter()
509        .map(|event| (event.kind.qualified_event_type(), event.timestamp))
510        .collect::<Vec<_>>();
511    assert_eq!(
512        event_timeline,
513        vec![
514            ("move_ordered".to_owned(), command_at),
515            ("army_arrived".to_owned(), arrival_at),
516            ("knowledge_updated".to_owned(), arrival_at),
517            ("report_dispatched".to_owned(), arrival_at),
518        ],
519        "the expected command and scheduled-arrival event timeline changed"
520    );
521    assert_eq!(
522        outcome.canonical_boundaries, 2,
523        "the command and arrival should settle at two canonical boundaries"
524    );
525}
526
527fn short_hash(hash: &str) -> &str {
528    hash.get(..12).unwrap_or(hash)
529}
530
531fn main() -> Result<(), CanwuError> {
532    println!(
533        "1x host policy: {BASE_SIM_SECONDS_PER_WALL_SECOND} simulation seconds per wall second"
534    );
535    println!("Frames are predefined; this example never sleeps or reads the real clock.");
536
537    let sixty = run(SIXTY_FPS_ISH, true)?;
538    let thirty = run(THIRTY_FPS_ISH, false)?;
539    assert_expected_outcome(&sixty);
540    assert_expected_outcome(&thirty);
541    assert_fps_independent(&sixty, &thirty);
542
543    println!(
544        "\nFPS INVARIANT: {} frames={} and {} frames={} -> time={}, events={}, boundaries={}, state_hash={}..., checkpoint={}...",
545        sixty.profile,
546        sixty.frames,
547        thirty.profile,
548        thirty.frames,
549        sixty.time,
550        sixty.events.len(),
551        sixty.canonical_boundaries,
552        short_hash(&sixty.authoritative_state_hash),
553        short_hash(&sixty.checkpoint_hash),
554    );
555    Ok(())
556}