library

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

commit ad6a6b6eb52c27345d76e4d1377af0a1ecc83675
parent 4f29e83d7962f664bea3dea4a1438e569781ad1e
Author: Bradley Gannon <bradley@bradleygannon.com>
Date:   Sun, 16 Aug 2026 14:50:31 -0400

Add client prediction and server reconciliation

Diffstat:
Mclient/src/game.rs | 20+++++++++-----------
Mclient/src/main.rs | 70+++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcommon/src/game.rs | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcommon/src/net.rs | 18+++++++-----------
Mserver/src/main.rs | 60++++++++++++++++++++++++++++++------------------------------
Mserver/src/net.rs | 23++++++++++++++++-------
6 files changed, 177 insertions(+), 95 deletions(-)

diff --git a/client/src/game.rs b/client/src/game.rs @@ -1,27 +1,25 @@ -use common::game::{Action, ActionKind}; +use common::game::ActionKind; use macroquad::prelude::*; -use uuid::Uuid; -pub(crate) fn handle_input(local_player_id: &Uuid) -> Option<Action> { - let velocity_x = if is_key_down(KeyCode::A) { +pub(crate) fn handle_input() -> Option<ActionKind> { + let velocity_x = if is_key_down(KeyCode::A) || is_key_down(KeyCode::Left) { -1. - } else if is_key_down(KeyCode::D) { + } else if is_key_down(KeyCode::D) || is_key_down(KeyCode::Right) { 1. } else { 0. }; - let velocity_y = if is_key_down(KeyCode::W) { + let velocity_y = if is_key_down(KeyCode::W) || is_key_down(KeyCode::Up) { -1. - } else if is_key_down(KeyCode::S) { + } else if is_key_down(KeyCode::S) || is_key_down(KeyCode::Down) { 1. } else { 0. }; if velocity_x != 0. || velocity_y != 0. { - Some(Action::new( - local_player_id.clone(), - ActionKind::Move(Vec2::new(velocity_x, velocity_y)), - )) + Some(ActionKind::Move { + velocity: Vec2::new(velocity_x, velocity_y), + }) } else { None } diff --git a/client/src/main.rs b/client/src/main.rs @@ -1,11 +1,7 @@ -use std::{ - sync::mpsc::channel, - thread, - time::{SystemTime, UNIX_EPOCH}, -}; +use std::{collections::VecDeque, sync::mpsc::channel, thread}; -use common::game::{Action, ActionKind, GameState, Response}; -use macroquad::{prelude::*, rand::srand}; +use common::game::{Action, ActionKind, GameState, ResponseKind}; +use macroquad::prelude::*; use crate::{ game::handle_input, @@ -32,45 +28,65 @@ async fn main() { ) }); - // seed the rng with current nanosecond timestamp to get (probably) different output on each run - srand( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .subsec_nanos() as u64, - ); - let mut camera = Camera::default(); - let mut game = GameState::default(); + let mut game_state = GameState::default(); let mut local_player_id = None; - //let _on_shutdown = OnShutdown::new(client_to_server_tx.clone(), local_player_id); + let mut sequence_number = 0; + let mut unresponded_actions = VecDeque::new(); loop { for response in server_to_client_rx.try_iter() { - match response { - Response::SetId { id } => { - let _ = client_to_server_tx.send(Action::new( + match response.kind() { + ResponseKind::SetId { id } => { + let action = Action::new( + sequence_number, id, ActionKind::Join { name: id.to_string().chars().take(3).collect(), }, - )); + ); + if client_to_server_tx.send(action.clone()).is_err() { + return; + } + unresponded_actions.push_back(action); + sequence_number = sequence_number.wrapping_add(1); local_player_id = Some(id); } - Response::UpdateGameState { + ResponseKind::UpdateGameState { game_state: new_game_state, - } => game = new_game_state, + } => { + // Drop any unresponded actions whose sequence numbers are less than the number + // we just got from the server + if let Some(new_front_index) = unresponded_actions + .iter() + .position(|ua| ua.sequence_number() >= response.sequence_number()) + { + for _ in 0..=new_front_index { + unresponded_actions.pop_front(); + } + } + + // Reapply the remaining unresponded actions to the latest server game state + game_state = new_game_state; + for unresponded_action in unresponded_actions.iter() { + game_state.apply(unresponded_action.clone()); + } + } }; } if let Some(id) = local_player_id { - if let Some(action) = handle_input(&id) { - if let Err(_) = client_to_server_tx.send(action) { + if let Some(action_kind) = handle_input() { + let action = Action::new(sequence_number, id, action_kind); + game_state.apply(action.clone()); + if client_to_server_tx.send(action.clone()).is_err() { return; } + unresponded_actions.push_back(action); + sequence_number = sequence_number.wrapping_add(1); } - follow_local_player(&mut camera, &game, &id); + follow_local_player(&mut camera, &game_state, &id); } - render(&camera, &game, &spritesheet); + render(&camera, &game_state, &spritesheet); next_frame().await; } } diff --git a/common/src/game.rs b/common/src/game.rs @@ -1,21 +1,39 @@ -use std::{collections::HashMap, time::SystemTime}; +use std::{ + collections::HashMap, + time::{Duration, SystemTime}, +}; use glam::Vec2; use serde::{Deserialize, Serialize}; use uuid::Uuid; +pub const TICK_DURATION: Duration = Duration::from_micros(16_667); + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct GameState { pub timestamp: SystemTime, - pub sequence_number: u32, pub players: HashMap<Uuid, Player>, } +impl GameState { + pub fn apply(&mut self, action: Action) { + match action.kind { + ActionKind::Join { name } => { + self.players.insert(action.player_id, Player::new(name)); + } + ActionKind::Move { velocity } => { + if let Some(player) = self.players.get_mut(&action.player_id) { + player.position += velocity; + } + } + } + } +} + impl Default for GameState { fn default() -> Self { Self { timestamp: SystemTime::now(), - sequence_number: 0, players: HashMap::with_capacity(32), } } @@ -28,22 +46,46 @@ pub struct Player { pub sprite_index: SpriteIndex, } +impl Player { + fn new(name: String) -> Self { + Player { + name, + position: Vec2::ZERO, + sprite_index: SpriteIndex::Meeple, + } + } +} + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct Action { - pub player_id: Uuid, - pub kind: ActionKind, + sequence_number: u32, + player_id: Uuid, + kind: ActionKind, } impl Action { - pub fn new(player_id: Uuid, kind: ActionKind) -> Self { - Action { player_id, kind } + pub fn new(sequence_number: u32, player_id: Uuid, kind: ActionKind) -> Self { + Action { + sequence_number, + player_id, + kind, + } + } + pub fn sequence_number(&self) -> u32 { + self.sequence_number + } + pub fn player_id(&self) -> Uuid { + self.player_id + } + pub fn kind(&self) -> ActionKind { + self.kind.clone() } } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub enum ActionKind { Join { name: String }, - Move(Vec2), + Move { velocity: Vec2 }, } #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] @@ -52,7 +94,28 @@ pub enum SpriteIndex { } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub enum Response { +pub struct Response { + sequence_number: u32, + kind: ResponseKind, +} + +impl Response { + pub fn new(sequence_number: u32, kind: ResponseKind) -> Self { + Response { + sequence_number, + kind, + } + } + pub fn sequence_number(&self) -> u32 { + self.sequence_number + } + pub fn kind(&self) -> ResponseKind { + self.kind.clone() + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub enum ResponseKind { SetId { id: Uuid }, UpdateGameState { game_state: GameState }, } diff --git a/common/src/net.rs b/common/src/net.rs @@ -33,13 +33,10 @@ pub fn handle_network_read<T: DeserializeOwned>( buffer_index = i; let messages = codec.decode(&buffer[..buffer_index]); for message in messages { - match postcard::from_bytes(message.as_slice()) { - Ok(m) => { - if let Err(_) = tx.send(m) { - break; - } - } - Err(_) => (), + if let Ok(m) = postcard::from_bytes(message.as_slice()) + && tx.send(m).is_err() + { + break; } } } @@ -59,8 +56,8 @@ pub fn handle_network_read<T: DeserializeOwned>( pub fn handle_network_write<T: Serialize>(stream: Arc<Mutex<TcpStream>>, rx: Receiver<T>) { loop { match rx.recv() { - Ok(m) => match postcard::to_stdvec(&m) { - Ok(to_write) => { + Ok(m) => { + if let Ok(to_write) = postcard::to_stdvec(&m) { let mut all_sent = false; while !all_sent { if let Ok(mut s) = stream.lock() { @@ -79,8 +76,7 @@ pub fn handle_network_write<T: Serialize>(stream: Arc<Mutex<TcpStream>>, rx: Rec } } } - Err(_) => (), - }, + } Err(_) => return, } } diff --git a/server/src/main.rs b/server/src/main.rs @@ -2,18 +2,16 @@ use std::{ collections::LinkedList, sync::{Arc, Mutex, mpsc::channel}, thread, - time::{Duration, Instant}, + time::Instant, }; -use common::game::{ActionKind, GameState, Player, Response, SpriteIndex}; -use glam::Vec2; +use common::game::{GameState, Response, ResponseKind, TICK_DURATION}; +use uuid::Uuid; use crate::net::listen; pub mod net; -const TICK_DURATION: Duration = Duration::from_millis(10); - fn main() { let clients = Arc::new(Mutex::new(LinkedList::new())); let (client_to_main_tx, client_to_main_rx) = channel(); @@ -24,42 +22,44 @@ fn main() { loop { let mut send_game_state = false; - // Process all pending player actions - for action in client_to_main_rx.try_iter() { - send_game_state = true; - match action.kind { - ActionKind::Join { name } => { - game_state.players.insert( - action.player_id.clone(), - Player { - name, - position: Vec2::ZERO, - sprite_index: SpriteIndex::Meeple, - }, - ); - } - ActionKind::Move(velocity) => { - if let Some(player) = game_state.players.get_mut(&action.player_id) { - player.position += velocity; - } + if let Ok(mut c) = clients.try_lock() { + // Process all pending player actions + for action in client_to_main_rx.try_iter() { + if let Some(client) = c + .iter_mut() + .find(|cl| cl.id().is_some() && cl.id().unwrap() == action.player_id()) + { + client.set_sequence_number(action.sequence_number()); + game_state.apply(action); + send_game_state = true; } } - } - if let Ok(mut c) = clients.try_lock() { // Clean up players associated with clients that have disconnected for dropped_client in c.extract_if(|client| client.is_finished()) { - game_state.players.remove(&dropped_client.id()); - send_game_state = true; + if let Some(id) = dropped_client.id() { + game_state.players.remove(&id); + send_game_state = true; + } + } + + // Set IDs for clients that are too new to have one + for client_without_id in c.iter_mut().filter(|cl| cl.id().is_none()) { + let id = Uuid::new_v4(); + let _ = client_without_id.send(Response::new(0, ResponseKind::SetId { id })); + client_without_id.set_id(id); } // Only bother to send the latest game status to clients if we really need to, instead // of every tick if send_game_state { for client in c.iter() { - let _ = client.send(Response::UpdateGameState { - game_state: game_state.clone(), - }); + let _ = client.send(Response::new( + client.sequence_number(), + ResponseKind::UpdateGameState { + game_state: game_state.clone(), + }, + )); } } } diff --git a/server/src/net.rs b/server/src/net.rs @@ -22,9 +22,10 @@ const TOKEN: Token = Token(0); #[derive(Debug)] pub(crate) struct Client { - id: Uuid, + id: Option<Uuid>, thread: JoinHandle<()>, tx: Sender<Response>, + sequence_number: u32, } impl Client { @@ -34,23 +35,31 @@ impl Client { main_to_client_tx: Sender<Response>, main_to_client_rx: Receiver<Response>, ) -> Self { - let new_client = Client { - id: Uuid::new_v4(), + Client { + id: None, thread: thread::spawn(|| handle_client(stream, main_to_client_rx, client_to_main_tx)), tx: main_to_client_tx, - }; - let _ = new_client.tx.send(Response::SetId { id: new_client.id }); - new_client + sequence_number: 0, + } } - pub(crate) fn id(&self) -> Uuid { + pub(crate) fn id(&self) -> Option<Uuid> { self.id } + pub(crate) fn set_id(&mut self, id: Uuid) { + self.id = Some(id); + } pub(crate) fn send(&self, t: Response) -> Result<(), SendError<Response>> { self.tx.send(t) } pub(crate) fn is_finished(&self) -> bool { self.thread.is_finished() } + pub(crate) fn sequence_number(&self) -> u32 { + self.sequence_number + } + pub(crate) fn set_sequence_number(&mut self, sequence_number: u32) { + self.sequence_number = sequence_number; + } } pub(crate) fn listen(clients: Arc<Mutex<LinkedList<Client>>>, tx: Sender<Action>) {