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:
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>) {