Skip to main content

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