Skip to main content

prediction_reconciliation/
prediction_reconciliation.rs

1use quaso::{
2    GameLauncher,
3    assets::{make_directory_database, shader::ShaderAsset},
4    config::Config,
5    context::GameContext,
6    game::{GameInstance, GameState, GameStateChange},
7    game_state_custom_event, inputs_bitstruct,
8    multiplayer::{
9        GameMultiplayerChange, GameNetwork,
10        csp_ssr::{CspSsrAuthority, CspSsrMultiplayer, CspSsrPlayerRole},
11        tcp::{TcpClientConnection, TcpServerConnection},
12        universal::{UniversalMultiplayerAuthority, UniversalMultiplayerGameState},
13    },
14    third_party::{
15        fontdue::layout::{HorizontalAlign, VerticalAlign},
16        raui_core::{
17            layout::CoordsMappingScaling,
18            widget::{
19                component::{
20                    image_box::ImageBoxProps, interactive::navigation::NavItemActive,
21                    text_box::TextBoxProps,
22                },
23                unit::text::{TextBoxFont, TextBoxHorizontalAlign, TextBoxVerticalAlign},
24                utils::Color,
25            },
26        },
27        raui_immediate_widgets::core::{
28            containers::{content_box, nav_horizontal_box},
29            image_box,
30            interactive::{ImmediateButton, button},
31            text_box,
32        },
33        spitfire_draw::{
34            context::DrawContext,
35            sprite::{Sprite, SpriteTexture},
36            text::Text,
37            utils::{Drawable, ShaderRef, TextureRef, Vertex},
38        },
39        spitfire_glow::{
40            graphics::{CameraScaling, GraphicsTarget, Shader},
41            renderer::GlowTextureFiltering,
42        },
43        spitfire_input::{
44            ArrayInputCombinator, InputActionRef, InputAxisRef, InputConsume, InputMapping,
45            MouseButton, VirtualAction, VirtualAxis,
46        },
47        tehuti::{
48            channel::Dispatch,
49            peer::{Peer, PeerId, TypedPeerRole},
50            replication::primitives::RepF32,
51        },
52        tehuti_timeline::time::TimeStamp,
53        time::Duration,
54        tracing::{debug, level_filters::LevelFilter},
55        tracing_subscriber::{
56            Layer, fmt::layer, layer::SubscriberExt, registry, util::SubscriberInitExt,
57        },
58        vek::{Rgba, Vec2},
59        windowing::event::VirtualKeyCode,
60    },
61};
62use serde::{Deserialize, Serialize};
63use std::{collections::HashMap, error::Error, sync::Arc};
64
65const ADDRESS: &str = "127.0.0.1:12345";
66const AUTHORITY_CLOCK_CHANNEL: u64 = 10;
67const PLAYER_ROLE: u64 = 1;
68const HISTORY_CAPACITY: usize = 16;
69const SERVER_SEND_STATE_INTERVAL: Duration = Duration::from_millis(1000 / 30);
70const CLIENT_SEND_INPUT_WINDOW: u64 = 4;
71const CLIENT_LEAD_TICKS: u64 = 2;
72const CLIENT_PING_INTERVAL: Duration = Duration::from_millis(250);
73const SPEED: f32 = 100.0;
74const COLOR_WHITE: Color = Color {
75    r: 1.0,
76    g: 1.0,
77    b: 1.0,
78    a: 1.0,
79};
80const COLOR_BLACK: Color = Color {
81    r: 0.0,
82    g: 0.0,
83    b: 0.0,
84    a: 1.0,
85};
86
87type PlayerRole = CspSsrPlayerRole<PLAYER_ROLE, HISTORY_CAPACITY, InputSnapshot, StateSnapshot>;
88type Authority = CspSsrAuthority<AUTHORITY_CLOCK_CHANNEL>;
89type Multiplayer = CspSsrMultiplayer<AUTHORITY_CLOCK_CHANNEL>;
90
91fn main() -> Result<(), Box<dyn Error>> {
92    registry()
93        .with(
94            layer()
95                .with_writer(std::io::stdout)
96                .with_filter(LevelFilter::DEBUG),
97        )
98        .init();
99
100    GameLauncher::new(GameInstance::new(Preloader).setup_assets(|assets| {
101        *assets = make_directory_database("./resources/").unwrap();
102    }))
103    .title("Netcode: Client-side prediction and server-side reconciliation")
104    .config(Config::load_from_file("./resources/GameConfig.toml")?)
105    .run();
106    Ok(())
107}
108
109#[derive(Default)]
110struct Preloader;
111
112impl GameState for Preloader {
113    fn enter(&mut self, context: GameContext) {
114        context.graphics.state.color = [0.2, 0.2, 0.2, 1.0];
115        context.graphics.state.main_camera.screen_alignment = 0.5.into();
116        context.graphics.state.main_camera.scaling = CameraScaling::FitVertical(500.0);
117        context.gui.coords_map_scaling = CoordsMappingScaling::FitVertical(500.0);
118
119        context
120            .assets
121            .spawn(
122                "shader://color",
123                (ShaderAsset::new(
124                    Shader::COLORED_VERTEX_2D,
125                    Shader::PASS_FRAGMENT,
126                ),),
127            )
128            .unwrap();
129        context
130            .assets
131            .spawn(
132                "shader://image",
133                (ShaderAsset::new(
134                    Shader::TEXTURED_VERTEX_2D,
135                    Shader::TEXTURED_FRAGMENT,
136                ),),
137            )
138            .unwrap();
139        context
140            .assets
141            .spawn(
142                "shader://text",
143                (ShaderAsset::new(Shader::TEXT_VERTEX, Shader::TEXT_FRAGMENT),),
144            )
145            .unwrap();
146
147        context.assets.ensure("font://roboto.ttf").unwrap();
148        context.assets.ensure("texture://ferris.png").unwrap();
149    }
150
151    fn update(&mut self, context: GameContext, _: f32) {
152        if !context.assets.is_busy() {
153            *context.state_change = GameStateChange::Swap(Box::new(Lobby::default()));
154        }
155    }
156}
157
158#[derive(Default)]
159struct Lobby {
160    exit: InputActionRef,
161}
162
163impl GameState for Lobby {
164    fn enter(&mut self, context: GameContext) {
165        let pointer_x = InputAxisRef::default();
166        let pointer_y = InputAxisRef::default();
167        let pointer_trigger = InputActionRef::default();
168        self.exit = InputActionRef::default();
169
170        context.gui.interactions.inputs.pointer_position =
171            ArrayInputCombinator::new([pointer_x.clone(), pointer_y.clone()]);
172        context.gui.interactions.inputs.pointer_trigger = pointer_trigger.clone();
173
174        context.input.push_mapping(
175            InputMapping::default()
176                .consume(InputConsume::Hit)
177                .axis(VirtualAxis::MousePositionX, pointer_x)
178                .axis(VirtualAxis::MousePositionY, pointer_y)
179                .action(
180                    VirtualAction::MouseButton(MouseButton::Left),
181                    pointer_trigger,
182                )
183                .action(
184                    VirtualAction::KeyButton(VirtualKeyCode::Escape),
185                    self.exit.clone(),
186                ),
187        );
188    }
189
190    fn exit(&mut self, context: GameContext) {
191        context.input.pop_mapping();
192    }
193
194    fn update(&mut self, context: GameContext, _delta_time: f32) {
195        if let Some(multiplayer) = context.multiplayer::<Multiplayer>()
196            && multiplayer.is_initialized()
197        {
198            *context.state_change = GameStateChange::Swap(Box::new(State::default()));
199        }
200    }
201
202    fn fixed_update(&mut self, context: GameContext, _delta_time: f32) {
203        if self.exit.get().is_pressed() {
204            *context.state_change = GameStateChange::Pop;
205        }
206    }
207
208    fn draw_gui(&mut self, context: GameContext) {
209        nav_horizontal_box(NavItemActive, || {
210            if context.multiplayer::<Multiplayer>().is_some() {
211                text_box(TextBoxProps {
212                    text: "ESTABLISHING CONNECTION...".to_owned(),
213                    font: TextBoxFont {
214                        name: "roboto.ttf".to_owned(),
215                        size: 24.0,
216                    },
217                    color: COLOR_WHITE,
218                    horizontal_align: TextBoxHorizontalAlign::Center,
219                    vertical_align: TextBoxVerticalAlign::Middle,
220                    ..Default::default()
221                });
222            } else {
223                let host = lobby_button("HOST");
224                let join = lobby_button("JOIN");
225
226                let is_server = if host.trigger_start() {
227                    Some(true)
228                } else if join.trigger_start() {
229                    Some(false)
230                } else {
231                    None
232                };
233
234                if let Some(is_server) = is_server {
235                    debug!(
236                        "Starting {}...",
237                        if is_server { "server" } else { "client" }
238                    );
239
240                    context.network.clear_connections();
241                    let mut network = GameNetwork::new(Arc::new(
242                        Authority::peer_factory(is_server)
243                            .unwrap()
244                            .with_typed::<PlayerRole>(),
245                    ));
246                    if is_server {
247                        network.add_connection(TcpServerConnection::listen(ADDRESS).unwrap());
248                    } else {
249                        network.add_connection(TcpClientConnection::connect(ADDRESS).unwrap());
250                    }
251                    if let Some(multiplayer) = Multiplayer::new(&network) {
252                        debug!("{} started", if is_server { "Server" } else { "Client" });
253
254                        *context.network = network;
255                        *context.multiplayer_change = GameMultiplayerChange::Set(Box::new(
256                            multiplayer
257                                .with_ticks_per_second(30)
258                                .with_server_send_state_interval(SERVER_SEND_STATE_INTERVAL)
259                                .with_client_lead_ticks(CLIENT_LEAD_TICKS)
260                                .with_client_ping_interval(CLIENT_PING_INTERVAL),
261                        ));
262                    }
263                }
264            }
265        });
266    }
267}
268
269fn lobby_button(label: &str) -> ImmediateButton {
270    button(NavItemActive, |state| {
271        let (bg_color, text_color) = if state.state.selected {
272            (COLOR_WHITE, COLOR_BLACK)
273        } else {
274            (COLOR_BLACK, COLOR_WHITE)
275        };
276
277        content_box((), || {
278            image_box(ImageBoxProps::colored(bg_color));
279
280            text_box(TextBoxProps {
281                text: label.to_owned(),
282                font: TextBoxFont {
283                    name: "roboto.ttf".to_owned(),
284                    size: 24.0,
285                },
286                color: text_color,
287                horizontal_align: TextBoxHorizontalAlign::Center,
288                vertical_align: TextBoxVerticalAlign::Middle,
289                ..Default::default()
290            });
291        });
292    })
293}
294
295#[derive(Default)]
296struct State {
297    players: HashMap<PeerId, PlayerCharacter>,
298    move_up: InputActionRef,
299    move_down: InputActionRef,
300    move_left: InputActionRef,
301    move_right: InputActionRef,
302    exit: InputActionRef,
303}
304
305impl State {
306    fn local_player_mut(&mut self) -> Option<&mut PlayerCharacter> {
307        self.players
308            .values_mut()
309            .find(|player| !player.role.info().remote)
310    }
311}
312
313impl GameState for State {
314    fn enter(&mut self, mut context: GameContext) {
315        self.move_left = InputActionRef::default();
316        self.move_right = InputActionRef::default();
317        self.move_up = InputActionRef::default();
318        self.move_down = InputActionRef::default();
319        self.exit = InputActionRef::default();
320        context.input.push_mapping(
321            InputMapping::default()
322                .consume(InputConsume::Hit)
323                .action(
324                    VirtualAction::KeyButton(VirtualKeyCode::A),
325                    self.move_left.clone(),
326                )
327                .action(
328                    VirtualAction::KeyButton(VirtualKeyCode::D),
329                    self.move_right.clone(),
330                )
331                .action(
332                    VirtualAction::KeyButton(VirtualKeyCode::W),
333                    self.move_up.clone(),
334                )
335                .action(
336                    VirtualAction::KeyButton(VirtualKeyCode::S),
337                    self.move_down.clone(),
338                )
339                .action(
340                    VirtualAction::KeyButton(VirtualKeyCode::Left),
341                    self.move_left.clone(),
342                )
343                .action(
344                    VirtualAction::KeyButton(VirtualKeyCode::Right),
345                    self.move_right.clone(),
346                )
347                .action(
348                    VirtualAction::KeyButton(VirtualKeyCode::Up),
349                    self.move_up.clone(),
350                )
351                .action(
352                    VirtualAction::KeyButton(VirtualKeyCode::Down),
353                    self.move_down.clone(),
354                )
355                .action(
356                    VirtualAction::KeyButton(VirtualKeyCode::Escape),
357                    self.exit.clone(),
358                ),
359        );
360
361        if let Some(multiplayer) = context.multiplayer_mut::<Multiplayer>() {
362            multiplayer.process_lifecycle_events = true;
363            let _ = multiplayer.authority.create_peer(PlayerRole::ROLE_ID);
364        } else {
365            *context.state_change = GameStateChange::Swap(Box::new(Lobby::default()));
366        }
367    }
368
369    fn exit(&mut self, context: GameContext) {
370        context.input.pop_mapping();
371
372        *context.network = GameNetwork::default();
373        *context.multiplayer_change = GameMultiplayerChange::Reset;
374    }
375
376    fn fixed_update(&mut self, context: GameContext, _delta_time: f32) {
377        if self.exit.get().is_pressed() {
378            *context.state_change = GameStateChange::Swap(Box::new(Lobby::default()));
379        }
380    }
381
382    fn draw(&mut self, context: GameContext) {
383        for player in self.players.values() {
384            player.draw(context.draw, context.graphics);
385        }
386    }
387
388    fn multiplayer_peer_added(&mut self, _context: GameContext, peer: Peer) {
389        let player = peer.into_typed::<PlayerRole>().unwrap();
390        self.players
391            .insert(player.info().peer_id, PlayerCharacter::new(player));
392    }
393
394    fn multiplayer_peer_removed(&mut self, _context: GameContext, peer_id: PeerId) {
395        self.players.remove(&peer_id);
396    }
397
398    game_state_custom_event! {
399        trait(UniversalMultiplayerGameState)
400    }
401}
402
403impl UniversalMultiplayerGameState for State {
404    fn prepare_frame(&mut self, _context: GameContext, current_tick: TimeStamp) {
405        for player in self.players.values_mut() {
406            if let Some(inputs) = player.role.inputs_mut() {
407                inputs.ensure_timestamp(current_tick, Default::default);
408            }
409            player
410                .role
411                .state_mut()
412                .ensure_timestamp(current_tick, Default::default);
413        }
414    }
415
416    fn time_travel(&mut self, _context: GameContext, target_tick: TimeStamp) {
417        for player in self.players.values_mut() {
418            if let Some(inputs) = player.role.inputs_mut() {
419                inputs.time_travel_to(target_tick);
420            }
421            player.role.state_mut().time_travel_to(target_tick);
422        }
423    }
424
425    fn handle_inputs(&mut self, _context: GameContext, current_tick: TimeStamp) {
426        let input = InputSnapshot::default()
427            .with_left(self.move_left.get().is_down())
428            .with_right(self.move_right.get().is_down())
429            .with_up(self.move_up.get().is_down())
430            .with_down(self.move_down.get().is_down());
431
432        if let Some(player) = self.local_player_mut() {
433            match &mut player.role {
434                PlayerRole::ServerLocal { input_history, .. } => {
435                    input_history.set(current_tick, input);
436                }
437                PlayerRole::ClientLocal {
438                    input_sender,
439                    input_history,
440                    ..
441                } => {
442                    input_history.set(current_tick, input);
443                    let since = current_tick - CLIENT_SEND_INPUT_WINDOW;
444                    if let Some(event) = input_history.collect_history(since..=current_tick) {
445                        input_sender.send(event.into()).ok();
446                    }
447                }
448                _ => {}
449            }
450        }
451    }
452
453    fn find_input_divergence(
454        &mut self,
455        _context: GameContext,
456        _current_tick: TimeStamp,
457        authority: UniversalMultiplayerAuthority,
458    ) -> Option<TimeStamp> {
459        if !authority.is_server() {
460            return None;
461        }
462
463        let mut divergence = None;
464
465        for player in self.players.values_mut() {
466            if let PlayerRole::ServerRemote {
467                input_receiver,
468                input_history,
469                ..
470            } = &mut player.role
471                && let Some(Dispatch { message, .. }) = input_receiver.last()
472            {
473                match input_history.apply_history_divergence(&message) {
474                    Ok(div) => {
475                        divergence = TimeStamp::possibly_oldest(divergence, div);
476                    }
477                    Err(error) => debug!(
478                        "Failed to apply input history divergence for player {}: {}",
479                        player.role.info().peer_id,
480                        error
481                    ),
482                }
483            }
484        }
485
486        divergence
487    }
488
489    fn find_state_divergence(
490        &mut self,
491        context: GameContext,
492        current_tick: TimeStamp,
493        authority: UniversalMultiplayerAuthority,
494    ) -> Option<TimeStamp> {
495        if !authority.is_client() {
496            return None;
497        }
498
499        let delta_time = context
500            .multiplayer::<Multiplayer>()
501            .unwrap()
502            .tick_delta_time;
503
504        let mut divergence = None;
505
506        for player in self.players.values_mut() {
507            match &mut player.role {
508                PlayerRole::ClientLocal {
509                    state_receiver,
510                    state_history,
511                    ..
512                } => {
513                    if let Some(Dispatch { message, .. }) = state_receiver.last() {
514                        match state_history.apply_history_divergence(&message) {
515                            Ok(div) => {
516                                divergence = TimeStamp::possibly_oldest(divergence, div);
517                            }
518                            Err(error) => debug!(
519                                "Failed to apply state history divergence for player {}: {}",
520                                player.role.info().peer_id,
521                                error
522                            ),
523                        }
524                    }
525                }
526                PlayerRole::ClientRemote {
527                    state_receiver,
528                    state_history,
529                    ..
530                } => {
531                    if let Some(Dispatch { message, .. }) = state_receiver.last() {
532                        match state_history.apply_history_divergence(&message) {
533                            Ok(Some(divergence)) => {
534                                if let Err(error) =
535                                    state_history.evolve(divergence, current_tick, |prev| {
536                                        let mut state = *prev;
537                                        state.position_x.0 += state.velocity_x.0 * delta_time;
538                                        state.position_y.0 += state.velocity_y.0 * delta_time;
539                                        Ok(state)
540                                    })
541                                {
542                                    debug!(
543                                        "Failed to evolve state history for player {}: {}",
544                                        player.role.info().peer_id,
545                                        error
546                                    );
547                                }
548                            }
549                            Ok(None) => {}
550                            Err(error) => {
551                                debug!(
552                                    "Failed to apply state history for player {}: {}",
553                                    player.role.info().peer_id,
554                                    error
555                                );
556                            }
557                        }
558                    }
559                }
560                _ => {}
561            }
562        }
563
564        divergence
565    }
566
567    fn send_state(
568        &mut self,
569        _context: GameContext,
570        current_tick: TimeStamp,
571        authority: UniversalMultiplayerAuthority,
572    ) {
573        if !authority.is_server() {
574            return;
575        }
576
577        for player in self.players.values() {
578            let (sender, history) = match &player.role {
579                PlayerRole::ServerLocal {
580                    state_sender,
581                    state_history,
582                    ..
583                } => (state_sender, state_history),
584                PlayerRole::ServerRemote {
585                    state_sender,
586                    state_history,
587                    ..
588                } => (state_sender, state_history),
589                _ => {
590                    continue;
591                }
592            };
593
594            if let Some(event) = history.collect_snapshot(current_tick)
595                && let Err(error) = sender.send(event.into())
596            {
597                debug!(
598                    "Failed to send state snapshot for player {}: {}",
599                    player.role.info().peer_id,
600                    error
601                );
602            }
603        }
604    }
605
606    fn tick(
607        &mut self,
608        _context: GameContext,
609        current_tick: TimeStamp,
610        delta_time: f32,
611        _resimulating: bool,
612    ) {
613        for player in self.players.values_mut() {
614            let input = player.role.inputs().map(|history| {
615                history
616                    .get_extrapolated(current_tick)
617                    .copied()
618                    .unwrap_or_default()
619            });
620
621            let states = player.role.state_mut();
622
623            let mut state = states
624                .get_extrapolated(current_tick)
625                .copied()
626                .unwrap_or_default();
627
628            if let Some(input) = input {
629                state.velocity_x.0 = match (input.left(), input.right()) {
630                    (true, false) => -SPEED,
631                    (false, true) => SPEED,
632                    _ => 0.0,
633                };
634                state.velocity_y.0 = match (input.up(), input.down()) {
635                    (true, false) => -SPEED,
636                    (false, true) => SPEED,
637                    _ => 0.0,
638                };
639            }
640
641            state.position_x.0 += state.velocity_x.0 * delta_time;
642            state.position_y.0 += state.velocity_y.0 * delta_time;
643
644            states.set(current_tick, state);
645        }
646    }
647
648    fn commit_frame(&mut self, _context: GameContext, current_tick: TimeStamp) {
649        for player in self.players.values_mut() {
650            let state = player
651                .role
652                .state_mut()
653                .get_extrapolated(current_tick)
654                .copied()
655                .unwrap_or_default();
656            player.sprite.transform.position.x = state.position_x.0;
657            player.sprite.transform.position.y = state.position_y.0;
658        }
659    }
660}
661
662// Inputs snapshot should only contain input down states, from which simulation
663// will deduce detailed changes between consecutive ticks.
664inputs_bitstruct! {
665    #[repr(transparent)]
666    #[derive(Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
667    struct InputSnapshot(u8) {
668        left: 0,
669        right: 1,
670        up: 2,
671        down: 3,
672    }
673}
674
675// State snapshots should store information required for simulation evolution,
676// so not only positions, but also velocities for example.
677// If we will only send positions, players would not be able to predict
678// their movement!
679#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
680struct StateSnapshot {
681    position_x: RepF32,
682    position_y: RepF32,
683    velocity_x: RepF32,
684    velocity_y: RepF32,
685}
686
687struct PlayerCharacter {
688    role: PlayerRole,
689    sprite: Sprite,
690}
691
692impl PlayerCharacter {
693    fn new(role: PlayerRole) -> Self {
694        Self {
695            role,
696            sprite: Sprite::single(SpriteTexture {
697                sampler: "u_image".into(),
698                texture: TextureRef::name("ferris.png"),
699                filtering: GlowTextureFiltering::Linear,
700            })
701            .pivot(0.5.into())
702            .scale(0.25.into()),
703        }
704    }
705}
706
707impl Drawable for PlayerCharacter {
708    fn draw(&self, context: &mut DrawContext, graphics: &mut dyn GraphicsTarget<Vertex>) {
709        self.sprite.draw(context, graphics);
710
711        Text::new(ShaderRef::name("text"))
712            .text(self.role.info().peer_id.id().to_string())
713            .font("roboto.ttf")
714            .size(20.0)
715            .position(Vec2::from(self.sprite.transform.position) - Vec2::new(0.0, 60.0))
716            .tint(if self.role.info().remote {
717                Rgba::red()
718            } else {
719                Rgba::white()
720            })
721            .horizontal_align(HorizontalAlign::Center)
722            .vertical_align(VerticalAlign::Bottom)
723            .draw(context, graphics);
724    }
725}