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
662inputs_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#[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}