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