library

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

commit fea35bf98f9cac945309f65b056f6e615e3acb1e
parent 454e22a82a7c36bd61373379159ed5c12b154716
Author: Bradley Gannon <bradley@bradleygannon.com>
Date:   Fri,  4 Sep 2026 15:44:56 -0400

Use structdiff for synchronizing GameState

Diffstat:
MCargo.lock | 22++++++++++++++++++++++
Mclient/Cargo.toml | 1+
Mclient/src/game/run.rs | 12+++---------
Mclient/src/game/state.rs | 26++++++++++++++------------
Mcommon/Cargo.toml | 1+
Mcommon/src/game/mod.rs | 24+++++++++++++++++-------
Mcommon/src/game/state.rs | 113++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mserver/Cargo.toml | 3++-
Mserver/src/main.rs | 85++++++++++++++++++++++++++++++++++---------------------------------------------
Mserver/src/net.rs | 28+++++-----------------------
10 files changed, 156 insertions(+), 159 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -68,6 +68,7 @@ dependencies = [ "mio", "postcard", "serde", + "structdiff", "uuid", ] @@ -96,6 +97,7 @@ dependencies = [ "mio", "postcard", "serde", + "structdiff", "uuid", ] @@ -531,6 +533,7 @@ dependencies = [ "mio", "postcard", "serde", + "structdiff", "uuid", ] @@ -547,6 +550,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] +name = "structdiff" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ded2e61f645b60f23f1ee69c122a77ba68dd187abb751c67ad2387bcb773f0b" +dependencies = [ + "serde", + "structdiff-derive", +] + +[[package]] +name = "structdiff-derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cec3cf082379c048a963041e18a8e741a4e0a1afb14b825bd4bd771366b9a6e" +dependencies = [ + "serde", +] + +[[package]] name = "syn" version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/client/Cargo.toml b/client/Cargo.toml @@ -13,4 +13,5 @@ macroquad = { version = "0.4.16", features = ["glam-serde"] } mio = { version = "1.2.2", features = ["net", "os-poll"] } postcard = { version = "1.1.3", default-features = false, features = ["use-std"] } serde = { version = "1.0.229", features = ["derive"] } +structdiff = { version = "0.7.3", features = ["debug_diffs", "serde"] } uuid = { version = "1.24.1", features = ["serde", "v4"] } diff --git a/client/src/game/run.rs b/client/src/game/run.rs @@ -107,16 +107,10 @@ impl ClientState { && let Some(local_player) = self.game.get_player_mut(&id) { // Send movement actions if requested - if let Some(action_kind) = handle_movement_input(local_player, &self.dt) - && let Some(ref mut tx) = self.tx - { + if let Some(action_kind) = handle_movement_input(local_player, &self.dt) { let action = Action::new(self.sequence_number, id, action_kind); - self.game.apply(&action); - if tx.send(action.clone()).is_err() { - return; - } - self.unresponded_actions.push_back(action); - self.sequence_number = self.sequence_number.wrapping_add(1); + self.game.apply_action(&action); + self.send_action(&action); } // Center local player on the screen diff --git a/client/src/game/state.rs b/client/src/game/state.rs @@ -1,6 +1,7 @@ use std::time::{Instant, SystemTime}; use common::game::{Action, ActionKind, Response, ResponseKind, state::GameState}; +use structdiff::StructDiff; use crate::game::{ClientState, ViewMode}; @@ -16,6 +17,15 @@ impl ClientState { pub(crate) fn advance_game_state(&mut self) { self.old_game = self.game.clone(); } + pub(crate) fn send_action(&mut self, action: &Action) { + if let Some(ref mut tx) = self.tx + && tx.send(action.clone()).is_err() + { + return; + } + self.unresponded_actions.push_back(action.clone()); + self.sequence_number = self.sequence_number.wrapping_add(1); + } pub(crate) fn handle_response(&mut self, response: &Response) { match response.kind() { ResponseKind::SetId { id } => { @@ -26,20 +36,12 @@ impl ClientState { name: self.config.player_name.clone(), }, ); - if let Some(ref mut tx) = self.tx - && tx.send(action.clone()).is_err() - { - return; - } - self.unresponded_actions.push_back(action); - self.sequence_number = self.sequence_number.wrapping_add(1); + self.send_action(&action); self.local_player_id = Some(id); } - ResponseKind::UpdateGameState { updates } => { - for update in updates { - self.game.update(&update); - } + ResponseKind::UpdateGameState { diffs } => { self.game.set_timestamp(response.timestamp()); + self.game.apply_mut(diffs); } ResponseKind::ReplaceGameState { new_game_state } => self.game = new_game_state, }; @@ -61,7 +63,7 @@ impl ClientState { // Client prediction: apply all unresponded actions to the game state self.interpolation_basis = SystemTime::now(); for unresponded_action in self.unresponded_actions.iter() { - self.game.apply(unresponded_action); + self.game.apply_action(unresponded_action); } } pub(crate) fn interpolate(&self) -> GameState { diff --git a/common/Cargo.toml b/common/Cargo.toml @@ -10,4 +10,5 @@ macroquad = { version = "0.4.16", features = ["glam-serde"] } mio = { version = "1.2.2", features = ["net", "os-poll"] } postcard = { version = "1.1.3", default-features = false, features = ["use-std"] } serde = { version = "1.0.229", features = ["derive"] } +structdiff = { version = "0.7.3", features = ["debug_diffs", "serde"] } uuid = { version = "1.24.1", features = ["serde", "v4"] } diff --git a/common/src/game/mod.rs b/common/src/game/mod.rs @@ -5,9 +5,10 @@ use std::{ use macroquad::math::{Rect, Vec2}; use serde::{Deserialize, Serialize}; +use structdiff::{Difference, StructDiff}; use uuid::Uuid; -use crate::game::state::{GameState, Update}; +use crate::game::state::GameState; pub mod book; pub mod state; @@ -21,7 +22,7 @@ pub type Players = HashMap<Uuid, Player>; fn rects_strictly_overlap(a: &Rect, b: &Rect) -> bool { a.left() < b.right() && a.right() > b.left() && a.top() < b.bottom() && a.bottom() > b.top() } -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, Difference, PartialEq, Serialize)] pub struct Player { name: String, position: Vec2, @@ -103,6 +104,9 @@ impl Action { pub fn sequence_number(&self) -> u32 { self.sequence_number } + pub fn timestamp(&self) -> SystemTime { + self.timestamp + } pub fn player_id(&self) -> Uuid { self.player_id } @@ -117,7 +121,7 @@ pub enum ActionKind { Move { new_position: Vec2 }, } -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct Response { timestamp: SystemTime, sequence_number: u32, @@ -143,9 +147,15 @@ impl Response { } } -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub enum ResponseKind { - SetId { id: Uuid }, - UpdateGameState { updates: Vec<Update> }, - ReplaceGameState { new_game_state: GameState }, + SetId { + id: Uuid, + }, + UpdateGameState { + diffs: Vec<<GameState as StructDiff>::Diff>, + }, + ReplaceGameState { + new_game_state: GameState, + }, } diff --git a/common/src/game/state.rs b/common/src/game/state.rs @@ -5,84 +5,36 @@ use macroquad::{ rand::{rand, srand}, }; use serde::{Deserialize, Serialize}; +use structdiff::{Difference, StructDiff}; use uuid::Uuid; use crate::game::{ - Action, ActionKind, Player, Players, + Action, ActionKind, Player, book::{BOOKS, Bookshelf, FloorBook}, rects_strictly_overlap, }; -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Difference, Deserialize, PartialEq, Serialize)] +#[difference(expose)] pub struct GameState { + #[difference(skip)] timestamp: SystemTime, - players: Players, + players: HashMap<Uuid, Player>, 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 }, -} - impl GameState { pub const MAX_PLAYER_SPEED_PX_S: f32 = 75.; - 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() { + pub fn apply_action(&mut self, action: &Action) { + match &action.kind { ActionKind::Join { name } => { - self.players.insert(action.player_id, Player::new(name)); + self.players + .insert(action.player_id, Player::new(name.to_string())); } ActionKind::Move { new_position } => { - if let Some(player) = self.players.get_mut(&action.player_id) - && let Ok(dt) = action.timestamp.duration_since(player.timestamp) - { - let velocity = (new_position - player.position) / dt.as_secs_f32(); - let limited_velocity = if velocity.length() > GameState::MAX_PLAYER_SPEED_PX_S { - velocity.normalize() * GameState::MAX_PLAYER_SPEED_PX_S - } else { - velocity - }; - player.position += limited_velocity * dt.as_secs_f32(); - - // Handle collisions with bookshelves - let player_collider = player.collider.rect().offset(player.position); - for bookshelf in self.bookshelves.iter() { - // If the player's movement would cause their collider to intersect with a - // bookshelf's collider, then compute the minimum axis-aligned motion - // required to make them touch but not intersect - let bookshelf_collider: Rect = bookshelf.bounds().into(); - let (a, b) = (bookshelf_collider, player_collider); - if rects_strictly_overlap(&a, &b) { - // Compute the required motion in each possible direction and select the - // smallest among them - let up = Vec2::new(0., a.top() - b.bottom()); - let down = Vec2::new(0., a.bottom() - b.top()); - let left = Vec2::new(a.left() - b.right(), 0.); - let right = Vec2::new(a.right() - b.left(), 0.); - let min_y = if up.y.abs() < down.y.abs() { up } else { down }; - let min_x = if right.x.abs() < left.x.abs() { - right - } else { - left - }; - let min = if min_x.x.abs() < min_y.y.abs() { - min_x - } else { - min_y - }; - player.position += min; - } - } - } + self.move_player(action.player_id, *new_position, action.timestamp); } } } @@ -149,6 +101,49 @@ impl GameState { pub fn floor_books(&self) -> &Vec<FloorBook> { self.floor_books.as_ref() } + pub fn move_player(&mut self, player_id: Uuid, new_position: Vec2, timestamp: SystemTime) { + if let Some(player) = self.players.get_mut(&player_id) + && let Ok(dt) = timestamp.duration_since(player.timestamp) + { + let velocity = (new_position - player.position) / dt.as_secs_f32(); + let limited_velocity = if velocity.length() > GameState::MAX_PLAYER_SPEED_PX_S { + velocity.normalize() * GameState::MAX_PLAYER_SPEED_PX_S + } else { + velocity + }; + player.position += limited_velocity * dt.as_secs_f32(); + + // Handle collisions with bookshelves + let player_collider = player.collider.rect().offset(player.position); + for bookshelf in self.bookshelves.iter() { + // If the player's movement would cause their collider to intersect with a + // bookshelf's collider, then compute the minimum axis-aligned motion + // required to make them touch but not intersect + let bookshelf_collider: Rect = bookshelf.bounds().into(); + let (a, b) = (bookshelf_collider, player_collider); + if rects_strictly_overlap(&a, &b) { + // Compute the required motion in each possible direction and select the + // smallest among them + let up = Vec2::new(0., a.top() - b.bottom()); + let down = Vec2::new(0., a.bottom() - b.top()); + let left = Vec2::new(a.left() - b.right(), 0.); + let right = Vec2::new(a.right() - b.left(), 0.); + let min_y = if up.y.abs() < down.y.abs() { up } else { down }; + let min_x = if right.x.abs() < left.x.abs() { + right + } else { + left + }; + let min = if min_x.x.abs() < min_y.y.abs() { + min_x + } else { + min_y + }; + player.position += min; + } + } + } + } } impl Default for GameState { diff --git a/server/Cargo.toml b/server/Cargo.toml @@ -10,4 +10,5 @@ glam = { version = "0.27.0", features = ["scalar-math", "serde"] } mio = { version = "1.2.2", features = ["net", "os-poll"] } postcard = { version = "1.1.3", default-features = false, features = ["use-std"] } serde = { version = "1.0.229", features = ["derive"] } -uuid = { version = "1.24.1", features = ["v4", "serde"] } +structdiff = { version = "0.7.3", features = ["debug_diffs", "serde"] } +uuid = { version = "1.24.1", features = ["serde", "v4"] } diff --git a/server/src/main.rs b/server/src/main.rs @@ -5,10 +5,8 @@ use std::{ time::Instant, }; -use common::game::{ - ActionKind, Response, ResponseKind, TICK_DURATION, - state::{GameState, Update}, -}; +use common::game::{Response, ResponseKind, TICK_DURATION, state::GameState}; +use structdiff::StructDiff; use uuid::Uuid; use crate::net::listen; @@ -23,11 +21,11 @@ fn main() { let clients_clone = clients.clone(); let _listener = thread::spawn(|| listen(clients_clone, client_to_main_tx)); let mut next_tick = Instant::now() + TICK_DURATION; + let mut previous_game_state; let mut game_state = GameState::default(); - let mut updates = vec![]; let mut full_state_counter = 0; loop { - updates.clear(); + previous_game_state = game_state.clone(); full_state_counter += 1; if let Ok(mut c) = clients.try_lock() { @@ -35,83 +33,74 @@ fn main() { 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()) + .find(|cl| cl.id.is_some() && cl.id.unwrap() == action.player_id()) { - client.set_sequence_number(action.sequence_number()); - 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(), - }) - } - } - } + client.sequence_number = action.sequence_number(); + game_state.apply_action(&action); } } // Clean up players associated with clients that have disconnected - for dropped_client in c.extract_if(|client| client.is_finished()) { - if let Some(id) = dropped_client.id() { + for dropped_client in c.extract_if(|client| client.thread.is_finished()) { + if let Some(id) = dropped_client.id { game_state.remove_player(&id); - updates.push(Update::RemovePlayer { player_id: id }); } } // 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()) { + 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( + let _ = client_without_id.tx.send(Response::new( game_state.get_timestamp(), 0, ResponseKind::SetId { id }, )); - let _ = client_without_id.send(Response::new( + let _ = client_without_id.tx.send(Response::new( game_state.get_timestamp(), 0, ResponseKind::ReplaceGameState { new_game_state: game_state.clone(), }, )); - client_without_id.set_id(id); + client_without_id.id = Some(id); } - // 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 { - updates: updates.clone(), - }, - )); - } + game_state.update_timestamp(); + // Send full game state if it's been long enough - } else if full_state_counter > TICKS_PER_FULL_GAME_STATE { + 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( + let _ = client.tx.send(Response::new( game_state.get_timestamp(), - client.sequence_number(), + client.sequence_number, ResponseKind::ReplaceGameState { new_game_state: game_state.clone(), }, )); } + // Send updates if there are any + } else { + let diffs = previous_game_state.diff(&game_state); + if !diffs.is_empty() { + let kind = ResponseKind::UpdateGameState { diffs }; + for client in c.iter() { + let _ = client.tx.send(Response::new( + game_state.get_timestamp(), + client.sequence_number, + kind.clone(), + )); + } + } } - } - // Throttling - let now = Instant::now(); - if now < next_tick { - thread::sleep(next_tick - now); + // Throttling + let now = Instant::now(); + if now < next_tick { + thread::sleep(next_tick - now); + } + next_tick = Instant::now() + TICK_DURATION; } - next_tick = Instant::now() + TICK_DURATION; } } diff --git a/server/src/net.rs b/server/src/net.rs @@ -2,7 +2,7 @@ use std::{ collections::LinkedList, sync::{ Arc, Mutex, - mpsc::{Receiver, SendError, Sender, channel}, + mpsc::{Receiver, Sender, channel}, }, thread::{self, JoinHandle}, time::Duration, @@ -22,10 +22,10 @@ const TOKEN: Token = Token(0); #[derive(Debug)] pub(crate) struct Client { - id: Option<Uuid>, - thread: JoinHandle<()>, - tx: Sender<Response>, - sequence_number: u32, + pub(crate) id: Option<Uuid>, + pub(crate) thread: JoinHandle<()>, + pub(crate) tx: Sender<Response>, + pub(crate) sequence_number: u32, } impl Client { @@ -42,24 +42,6 @@ impl Client { sequence_number: 0, } } - 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<(), Box<SendError<Response>>> { - self.tx.send(t).map_err(|e| e.into()) - } - 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>) {