library

Online co-op book organization video game
git clone https://git.bradleygannon.com/library.git
Log | Files | Refs

commit 05567c02bf2b514629f80628601e7b61691f5aff
parent dee0edbf6f241b54110cfcf6136cdeff7678cb78
Author: Bradley Gannon <bradley@bradleygannon.com>
Date:   Sun, 30 Aug 2026 13:03:50 -0400

Reduce data rate by sending small updates most of the time

Diffstat:
Mclient/src/main.rs | 57+++++++++++++++++++++++++++++++++++----------------------
Mcommon/src/game.rs | 40++++++++++++++++++++++++++++++++++------
Mserver/src/main.rs | 61++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mserver/src/net.rs | 4++--
4 files changed, 121 insertions(+), 41 deletions(-)

diff --git a/client/src/main.rs b/client/src/main.rs @@ -50,7 +50,16 @@ async fn main() { t1 = Instant::now(); dt = t1.duration_since(t0); + let mut response_count: u8 = 0; + let mut latest_sequence_number = 0; for response in server_to_client_rx.try_iter() { + response_count = response_count.saturating_add(1); + // If this is the first response that we're processing during this tick, then we need to + // copy the current game state so we can interpolate against it below + if response_count == 1 { + old_game_state = game_state.clone(); + } + latest_sequence_number = latest_sequence_number.max(response.sequence_number()); match response.kind() { ResponseKind::SetId { id } => { let action = Action::new( @@ -67,37 +76,41 @@ async fn main() { sequence_number = sequence_number.wrapping_add(1); local_player_id = Some(id); } - ResponseKind::UpdateGameState { - game_state: new_game_state, - } => { - old_game_state = game_state.clone(); - - // 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; - interpolation_basis = SystemTime::now(); - for unresponded_action in unresponded_actions.iter() { - game_state.apply(unresponded_action.clone()); + ResponseKind::UpdateGameState { updates } => { + for update in updates { + game_state.update(&update); } + game_state.set_timestamp(response.timestamp()); } + ResponseKind::ReplaceGameState { new_game_state } => game_state = new_game_state, }; } + // If there were any server responses, then do server reconciliation and client prediction + if response_count > 0 { + // Server reconciliation: 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: &Action| ua.sequence_number() >= latest_sequence_number) + { + for _ in 0..=new_front_index { + unresponded_actions.pop_front(); + } + } + + // Client prediction: reapply the remaining unresponded actions to the latest server + // game state + interpolation_basis = SystemTime::now(); + for unresponded_action in unresponded_actions.iter() { + game_state.apply(unresponded_action); + } + } if let Some(id) = local_player_id && let Some(local_player) = game_state.get_player_mut(&id) { if let Some(action_kind) = handle_movement_input(local_player, &dt) { let action = Action::new(sequence_number, id, action_kind); - game_state.apply(action.clone()); + game_state.apply(&action); if client_to_server_tx.send(action.clone()).is_err() { return; } diff --git a/common/src/game.rs b/common/src/game.rs @@ -23,15 +23,22 @@ lazy_static! { pub const TICK_DURATION: Duration = Duration::from_micros(16_667); pub type BookId = Uuid; +pub type Players = HashMap<Uuid, Player>; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct GameState { timestamp: SystemTime, - players: HashMap<Uuid, Player>, + players: Players, bookshelves: Vec<Bookshelf>, floor_books: Vec<FloorBook>, } +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub enum Update { + InsertPlayer { id: Uuid, player: Player }, + RemovePlayer { player_id: Uuid }, +} + /// Equivalent to [macroquad::math::Rect::overlaps], except using strict comparison (i.e., `<` and /// `>` instead of `<=` and `>=`) fn rects_strictly_overlap(a: &Rect, b: &Rect) -> bool { @@ -41,8 +48,14 @@ fn rects_strictly_overlap(a: &Rect, b: &Rect) -> bool { impl GameState { pub const MAX_PLAYER_SPEED_PX_S: f32 = 75.; - pub fn apply(&mut self, action: Action) { - match action.kind { + pub fn update(&mut self, update: &Update) { + match update { + Update::InsertPlayer { id, player } => self.players.insert(*id, player.clone()), + Update::RemovePlayer { player_id } => self.players.remove(player_id), + }; + } + pub fn apply(&mut self, action: &Action) { + match action.kind() { ActionKind::Join { name } => { self.players.insert(action.player_id, Player::new(name)); } @@ -133,11 +146,20 @@ impl GameState { pub fn players(&self) -> &HashMap<Uuid, Player> { &self.players } + pub fn insert_player(&mut self, id: Uuid, player: Player) -> Option<Player> { + self.players.insert(id, player) + } pub fn remove_player(&mut self, id: &Uuid) -> Option<Player> { self.players.remove(id) } + pub fn set_timestamp(&mut self, timestamp: SystemTime) { + self.timestamp = timestamp; + } + pub fn get_timestamp(&self) -> SystemTime { + self.timestamp + } pub fn update_timestamp(&mut self) { - self.timestamp = SystemTime::now(); + self.set_timestamp(SystemTime::now()); } pub fn bookshelves(&self) -> &Vec<Bookshelf> { self.bookshelves.as_ref() @@ -469,17 +491,22 @@ pub enum SpriteIndex { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct Response { + timestamp: SystemTime, sequence_number: u32, kind: ResponseKind, } impl Response { - pub fn new(sequence_number: u32, kind: ResponseKind) -> Self { + pub fn new(timestamp: SystemTime, sequence_number: u32, kind: ResponseKind) -> Self { Response { + timestamp, sequence_number, kind, } } + pub fn timestamp(&self) -> SystemTime { + self.timestamp + } pub fn sequence_number(&self) -> u32 { self.sequence_number } @@ -491,5 +518,6 @@ impl Response { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub enum ResponseKind { SetId { id: Uuid }, - UpdateGameState { game_state: GameState }, + UpdateGameState { updates: Vec<Update> }, + ReplaceGameState { new_game_state: GameState }, } diff --git a/server/src/main.rs b/server/src/main.rs @@ -5,13 +5,15 @@ use std::{ time::Instant, }; -use common::game::{GameState, Response, ResponseKind, TICK_DURATION}; +use common::game::{ActionKind, GameState, Response, ResponseKind, TICK_DURATION, Update}; use uuid::Uuid; use crate::net::listen; pub mod net; +const TICKS_PER_FULL_GAME_STATE: u32 = (5. / TICK_DURATION.as_secs_f32()) as u32; + fn main() { let clients = Arc::new(Mutex::new(LinkedList::new())); let (client_to_main_tx, client_to_main_rx) = channel(); @@ -19,8 +21,11 @@ fn main() { let _listener = thread::spawn(|| listen(clients_clone, client_to_main_tx)); let mut next_tick = Instant::now() + TICK_DURATION; let mut game_state = GameState::default(); + let mut updates = vec![]; + let mut full_state_counter = 0; loop { - let mut send_game_state = false; + updates.clear(); + full_state_counter += 1; if let Ok(mut c) = clients.try_lock() { // Process all pending player actions @@ -30,8 +35,17 @@ fn main() { .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; + game_state.apply(&action); + match action.kind() { + ActionKind::Join { .. } | ActionKind::Move { .. } => { + if let Some(player) = game_state.get_player(&action.player_id()) { + updates.push(Update::InsertPlayer { + id: action.player_id(), + player: player.clone(), + }) + } + } + } } } @@ -39,26 +53,51 @@ fn main() { for dropped_client in c.extract_if(|client| client.is_finished()) { if let Some(id) = dropped_client.id() { game_state.remove_player(&id); - send_game_state = true; + updates.push(Update::RemovePlayer { player_id: id }); } } - // Set IDs for clients that are too new to have one + // Set IDs for clients that are too new to have one, and also send those clients the + // full current game state 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 })); + let _ = client_without_id.send(Response::new( + game_state.get_timestamp(), + 0, + ResponseKind::SetId { id }, + )); + let _ = client_without_id.send(Response::new( + game_state.get_timestamp(), + 0, + ResponseKind::ReplaceGameState { + new_game_state: game_state.clone(), + }, + )); 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 { + // Send updates if there are any + if !updates.is_empty() { game_state.update_timestamp(); for client in c.iter() { let _ = client.send(Response::new( + game_state.get_timestamp(), client.sequence_number(), ResponseKind::UpdateGameState { - game_state: game_state.clone(), + updates: updates.clone(), + }, + )); + } + // Send full game state if it's been long enough + } else if full_state_counter > TICKS_PER_FULL_GAME_STATE { + full_state_counter = 0; + game_state.update_timestamp(); + for client in c.iter() { + let _ = client.send(Response::new( + game_state.get_timestamp(), + client.sequence_number(), + ResponseKind::ReplaceGameState { + new_game_state: game_state.clone(), }, )); } diff --git a/server/src/net.rs b/server/src/net.rs @@ -48,8 +48,8 @@ impl Client { 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 send(&self, t: Response) -> Result<(), Box<SendError<Response>>> { + self.tx.send(t).map_err(|e| e.into()) } pub(crate) fn is_finished(&self) -> bool { self.thread.is_finished()