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