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, EntityRef, Issuer, SimDuration, SimEvent, SimTime, TerritoryId,
10    TransitState, 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::OrderMovement {
270                subject: EntityRef::Army(self.ids.army),
271                destination: self.ids.eastern_territory,
272                cargo: Vec::new(),
273            },
274        )
275        .at_time(due_at);
276        let request = CommandRequest::new(
277            CommandRequestId::new(1),
278            self.authority.revision(),
279            envelope,
280        );
281        let ingress = self.authority.enqueue_command(due_at, 0, request)?;
282
283        if self.verbose {
284            println!(
285                "INPUT: queued commander move as {:?}, captured_at={captured_at}, quantized_due_at={due_at}",
286                ingress.ingress_id,
287            );
288        }
289
290        // This drains ingress due at the current canonical time only. The
291        // mid-quantum command remains queued until normal accumulation reaches
292        // its quantized minute; future scheduled work is never jumped to.
293        let receipts = self.authority.advance_canonical(SimDuration::ZERO)?;
294        self.canonical_boundaries += receipts.len();
295        self.refresh_presentation_from_authority();
296        self.presentation.render(self.presentation_time());
297        if self.verbose {
298            self.print_frame(GameSpeed::X1);
299        }
300        self.report_new_events();
301        Ok(())
302    }
303
304    fn refresh_presentation_from_authority(&mut self) {
305        self.presentation
306            .refresh_from_authority(&self.authority.world(), self.ids.army);
307    }
308
309    fn presentation_time(&self) -> PresentationTime {
310        PresentationTime {
311            canonical: self.authority.time(),
312            sub_minute: Duration::from_nanos(
313                u64::try_from(self.accumulated_sim_nanos)
314                    .expect("the sub-minute accumulator must fit a Duration"),
315            ),
316        }
317    }
318
319    fn crossed_progress_milestone(&mut self, progress: Option<f64>) -> bool {
320        let Some(progress) = progress else {
321            return false;
322        };
323        let mut crossed = false;
324        while self.next_progress_milestone <= 100
325            && progress * 100.0 + f64::EPSILON >= f64::from(self.next_progress_milestone)
326        {
327            crossed = true;
328            self.next_progress_milestone += 25;
329        }
330        crossed
331    }
332
333    fn print_frame(&self, speed: GameSpeed) {
334        println!(
335            "frame={:>5} speed={:<6} wall={:>7.3}s visual_time={} canwu_time={} {}",
336            self.frame,
337            speed.label(),
338            self.wall_elapsed.as_secs_f64(),
339            self.presentation_time(),
340            self.authority.time(),
341            self.presentation.describe(),
342        );
343    }
344
345    fn report_new_events(&mut self) {
346        let events = self.authority.events();
347        if self.verbose {
348            for event in &events[self.reported_events..] {
349                println!(
350                    "EVENT: at={} type={} {}",
351                    event.timestamp,
352                    event.kind.qualified_event_type(),
353                    event.summary,
354                );
355            }
356        }
357        self.reported_events = events.len();
358    }
359
360    fn outcome(self, profile: FrameProfile) -> Result<RunOutcome, CanwuError> {
361        Ok(RunOutcome {
362            profile: profile.name,
363            frames: self.frame,
364            time: self.authority.time(),
365            world: self.authority.world(),
366            events: self.authority.events().to_vec(),
367            commands: self.authority.commands().to_vec(),
368            authoritative_state_hash: self.authority.authoritative_state_hash()?,
369            checkpoint_hash: self.authority.checkpoint_hash().to_owned(),
370            canonical_boundaries: self.canonical_boundaries,
371        })
372    }
373}
374
375struct RunOutcome {
376    profile: &'static str,
377    frames: u64,
378    time: SimTime,
379    world: WorldSnapshot,
380    events: Vec<SimEvent>,
381    commands: Vec<CommandRecord>,
382    authoritative_state_hash: String,
383    checkpoint_hash: String,
384    canonical_boundaries: usize,
385}
386
387fn run(profile: FrameProfile, verbose: bool) -> Result<RunOutcome, CanwuError> {
388    let mut host = GameHost::new(verbose)?;
389    if verbose {
390        println!("\n{} render loop", profile.name);
391    }
392
393    host.run_phase(SPEED_SCHEDULE[0], profile)?;
394    host.submit_player_command()?;
395    host.run_phase(SPEED_SCHEDULE[1], profile)?;
396    host.run_phase(SPEED_SCHEDULE[2], profile)?;
397
398    let time_before_pause = host.authority.time();
399    let remainder_before_pause = host.accumulated_sim_nanos;
400    let presentation_before_pause = host.presentation_time();
401    let frames_before_pause = host.frame;
402    assert!(
403        remainder_before_pause > 0,
404        "the pause fixture must preserve a fractional simulation minute"
405    );
406    host.run_phase(SPEED_SCHEDULE[3], profile)?;
407    assert!(
408        host.frame > frames_before_pause,
409        "pause must keep rendering"
410    );
411    assert_eq!(
412        host.authority.time(),
413        time_before_pause,
414        "pause must not advance Canwu"
415    );
416    assert_eq!(
417        host.accumulated_sim_nanos, remainder_before_pause,
418        "pause must not add desired simulation time"
419    );
420    assert_eq!(
421        host.presentation_time(),
422        presentation_before_pause,
423        "pause must freeze presentation time"
424    );
425
426    host.run_phase(SPEED_SCHEDULE[4], profile)?;
427    assert_eq!(
428        host.accumulated_sim_nanos, 0,
429        "the scripted schedule should end on a simulation-minute boundary"
430    );
431    host.outcome(profile)
432}
433
434fn std_duration(duration: SimDuration) -> Duration {
435    let minutes = u64::try_from(duration.as_minutes())
436        .expect("presentation interpolation requires a non-negative duration");
437    Duration::from_secs(
438        minutes
439            .checked_mul(60)
440            .expect("presentation duration must remain representable"),
441    )
442}
443
444fn assert_fps_independent(left: &RunOutcome, right: &RunOutcome) {
445    assert_ne!(
446        left.frames, right.frames,
447        "the render segmentations must differ"
448    );
449    assert_eq!(left.time, right.time, "Canwu time changed with render FPS");
450    assert_eq!(
451        left.world, right.world,
452        "world state changed with render FPS"
453    );
454    assert_eq!(left.events, right.events, "events changed with render FPS");
455    assert_eq!(
456        left.commands, right.commands,
457        "command ordering changed with render FPS"
458    );
459    assert_eq!(
460        left.authoritative_state_hash, right.authoritative_state_hash,
461        "authoritative state hash changed with render FPS"
462    );
463    assert_eq!(
464        left.checkpoint_hash, right.checkpoint_hash,
465        "checkpoint hash changed with render FPS"
466    );
467    assert_eq!(
468        left.canonical_boundaries, right.canonical_boundaries,
469        "canonical boundary count changed with render FPS"
470    );
471}
472
473fn assert_expected_outcome(outcome: &RunOutcome) {
474    let ids = Canwu::demo_ids();
475    let command_at = SimTime::from_minutes(1);
476    let arrival_at = SimTime::from_minutes(1_081);
477    let final_time = SimTime::from_minutes(1_082);
478
479    assert_eq!(outcome.time, final_time, "the scripted final time changed");
480    let army = outcome
481        .world
482        .army(ids.army)
483        .expect("the demo army must remain present");
484    assert_eq!(
485        army.location, ids.eastern_territory,
486        "the scheduled arrival must execute"
487    );
488    assert!(
489        army.transit.is_none(),
490        "the army must no longer be in authoritative transit"
491    );
492
493    assert_eq!(
494        outcome.commands.len(),
495        1,
496        "the move command must be accepted"
497    );
498    let command = &outcome.commands[0];
499    assert_eq!(command.accepted_at, command_at);
500    assert_eq!(command.envelope.expected_time, Some(command_at));
501    assert!(matches!(
502        &command.envelope.command,
503        Command::OrderMovement {
504            subject: EntityRef::Army(army),
505            destination,
506            cargo,
507        } if *army == ids.army
508            && *destination == ids.eastern_territory
509            && cargo.is_empty()
510    ));
511
512    let event_timeline = outcome
513        .events
514        .iter()
515        .map(|event| (event.kind.qualified_event_type(), event.timestamp))
516        .collect::<Vec<_>>();
517    assert_eq!(
518        event_timeline,
519        vec![
520            ("move_ordered".to_owned(), command_at),
521            ("army_arrived".to_owned(), arrival_at),
522            ("knowledge_updated".to_owned(), arrival_at),
523            ("report_dispatched".to_owned(), arrival_at),
524        ],
525        "the expected command and scheduled-arrival event timeline changed"
526    );
527    assert_eq!(
528        outcome.canonical_boundaries, 2,
529        "the command and arrival should settle at two canonical boundaries"
530    );
531}
532
533fn short_hash(hash: &str) -> &str {
534    hash.get(..12).unwrap_or(hash)
535}
536
537fn main() -> Result<(), CanwuError> {
538    println!(
539        "1x host policy: {BASE_SIM_SECONDS_PER_WALL_SECOND} simulation seconds per wall second"
540    );
541    println!("Frames are predefined; this example never sleeps or reads the real clock.");
542
543    let sixty = run(SIXTY_FPS_ISH, true)?;
544    let thirty = run(THIRTY_FPS_ISH, false)?;
545    assert_expected_outcome(&sixty);
546    assert_expected_outcome(&thirty);
547    assert_fps_independent(&sixty, &thirty);
548
549    println!(
550        "\nFPS INVARIANT: {} frames={} and {} frames={} -> time={}, events={}, boundaries={}, state_hash={}..., checkpoint={}...",
551        sixty.profile,
552        sixty.frames,
553        thirty.profile,
554        thirty.frames,
555        sixty.time,
556        sixty.events.len(),
557        sixty.canonical_boundaries,
558        short_hash(&sixty.authoritative_state_hash),
559        short_hash(&sixty.checkpoint_hash),
560    );
561    Ok(())
562}