Skip to content

Commit

Permalink
Merge pull request #88 from caspark/fix-max-rollback-and-disconnects-…
Browse files Browse the repository at this point in the history
…and-lockstep

Fix max rollback and lockstep handling
  • Loading branch information
gschup authored Dec 15, 2024
2 parents 534170e + dd8b30d commit c2814a1
Show file tree
Hide file tree
Showing 8 changed files with 124 additions and 48 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ In this document, all remarkable changes are listed. Not mentioned are smaller c

## Unreleased

- lockstep determinism is now possible by setting max predictions to 0
- allow non-`Clone` types to be stored in `GameStateCell`.
- added `SyncTestSession::current_frame()` and `SpectatorSession::current_frame()` to match the existing `P2PSession::current_frame()`.
- added `P2PSession::desync_detection()` to read the session's desync detection mode.
Expand Down
18 changes: 15 additions & 3 deletions examples/ex_game/ex_game.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,23 @@ impl Game {
}

// for each request, call the appropriate function
pub fn handle_requests(&mut self, requests: Vec<GgrsRequest<GGRSConfig>>) {
pub fn handle_requests(&mut self, requests: Vec<GgrsRequest<GGRSConfig>>, in_lockstep: bool) {
for request in requests {
match request {
GgrsRequest::LoadGameState { cell, .. } => self.load_game_state(cell),
GgrsRequest::SaveGameState { cell, frame } => self.save_game_state(cell, frame),
GgrsRequest::LoadGameState { cell, .. } => {
if in_lockstep {
unreachable!("Should never get a load request if running in lockstep")
} else {
self.load_game_state(cell)
}
}
GgrsRequest::SaveGameState { cell, frame } => {
if in_lockstep {
unreachable!("Should never get a save request if running in lockstep")
} else {
self.save_game_state(cell, frame)
}
}
GgrsRequest::AdvanceFrame { inputs } => self.advance_frame(inputs),
}
}
Expand Down
14 changes: 10 additions & 4 deletions examples/ex_game/ex_game_p2p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.with_desync_detection_mode(ggrs::DesyncDetection::On { interval: 100 })
// (optional) set expected update frequency
.with_fps(FPS as usize)?
// secret trick: set the prediction to 0 to fall back to lockstep netcode
//.with_max_prediction_window(0)
// (optional) customize prediction window, which is how many frames ahead GGRS predicts.
// Or set the prediction window to 0 to use lockstep netcode instead (i.e. no rollbacks).
.with_max_prediction_window(8)
// (optional) set input delay for the local player
.with_input_delay(2);
.with_input_delay(2)
// (optional) by default, GGRS will ask you to save the game state every frame. If your
// saving of game state takes much longer than advancing the game state N times, you can
// improve performance by turning sparse saving mode on (N == average number of predictions
// GGRS must make, which is determined by prediction window, FPS and latency to clients).
.with_sparse_saving_mode(false);

// add players
for (i, player_addr) in opt.players.iter().enumerate() {
Expand Down Expand Up @@ -113,7 +119,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}

match sess.advance_frame() {
Ok(requests) => game.handle_requests(requests),
Ok(requests) => game.handle_requests(requests, sess.in_lockstep_mode()),
Err(e) => return Err(Box::new(e)),
}
}
Expand Down
2 changes: 1 addition & 1 deletion examples/ex_game/ex_game_spectator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// execute a frame
if sess.current_state() == SessionState::Running {
match sess.advance_frame() {
Ok(requests) => game.handle_requests(requests),
Ok(requests) => game.handle_requests(requests, false),
Err(GgrsError::PredictionThreshold) => {
println!(
"Frame {} skipped: Waiting for input from host.",
Expand Down
2 changes: 1 addition & 1 deletion examples/ex_game/ex_game_synctest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}

match sess.advance_frame() {
Ok(requests) => game.handle_requests(requests),
Ok(requests) => game.handle_requests(requests, false),
Err(e) => return Err(Box::new(e)),
}
}
Expand Down
21 changes: 18 additions & 3 deletions src/sessions/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,19 @@ impl<T: Config> SessionBuilder<T> {
}

/// Change the maximum prediction window. Default is 8.
///
/// ## Lockstep mode
///
/// As a special case, if you set this to 0, GGRS will run in lockstep mode:
/// * ggrs will only request that you advance the gamestate if the current frame has inputs
/// confirmed from all other clients.
/// * ggrs will never request you to save or roll back the gamestate.
///
/// Lockstep mode can significantly reduce the (GGRS) framerate of your game, but may be
/// appropriate for games where a GGRS frame does not correspond to a rendered frame, such as a
/// game where GGRS frames are only advanced once a second; with input delay set to zero, the
/// framerate impact is approximately equivalent to taking the highest latency client and adding
/// its latency to the current time to tick a frame.
pub fn with_max_prediction_window(mut self, window: usize) -> Self {
self.max_prediction = window;
self
Expand All @@ -145,9 +158,11 @@ impl<T: Config> SessionBuilder<T> {
self
}

/// Sets the sparse saving mode. With sparse saving turned on, only the minimum confirmed frame (for which all inputs from all players are confirmed correct) will be saved.
/// This leads to much less save requests at the cost of potentially longer rollbacks and thus more advance frame requests. Recommended, if saving your gamestate
/// takes much more time than advancing the game state.
/// Sets the sparse saving mode. With sparse saving turned on, only the minimum confirmed frame
/// (for which all inputs from all players are confirmed correct) will be saved. This leads to
/// much less save requests at the cost of potentially longer rollbacks and thus more advance
/// frame requests. Recommended, if saving your gamestate takes much more time than advancing
/// the game state.
pub fn with_sparse_saving_mode(mut self, sparse_saving: bool) -> Self {
self.sparse_saving = sparse_saving;
self
Expand Down
79 changes: 59 additions & 20 deletions src/sessions/p2p_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,15 @@ impl<T: Config> P2PSession<T> {
SessionState::Synchronizing
};

let sparse_saving = if max_prediction == 0 {
// in lockstep mode, saving will never happen, but we use the last saved frame to mark
// control marking frames confirmed, so we need to turn off sparse saving to ensure that
// frames are marked as confirmed - otherwise we will never advance the game state.
false
} else {
sparse_saving
};

Self {
state,
num_players,
Expand Down Expand Up @@ -292,8 +301,13 @@ impl<T: Config> P2PSession<T> {
* ROLLBACKS AND GAME STATE MANAGEMENT
*/

// if in lockstep mode, we will only ever request to advance the frame when all inputs for
// the current frame have been confirmed; therefore there's no need to roll back, and hence
// no need to ever save the game state either.
let lockstep = self.in_lockstep_mode();

// if we are in the first frame, we have to save the state
if self.sync_layer.current_frame() == 0 {
if self.sync_layer.current_frame() == 0 && !lockstep {
requests.push(self.sync_layer.save_current_state());
}

Expand All @@ -303,22 +317,27 @@ impl<T: Config> P2PSession<T> {
// find the confirmed frame for which we received all inputs
let confirmed_frame = self.confirmed_frame();

// check game consistency and rollback, if necessary.
// The disconnect frame indicates if a rollback is necessary due to a previously disconnected player
let first_incorrect = self
.sync_layer
.check_simulation_consistency(self.disconnect_frame);
if first_incorrect != NULL_FRAME {
self.adjust_gamestate(first_incorrect, confirmed_frame, &mut requests);
self.disconnect_frame = NULL_FRAME;
}
// check game consistency and roll back, if necessary
if !lockstep {
// the disconnect frame indicates if a rollback is necessary due to a previously
// disconnected player (whose input would have been incorrectly predicted).
let first_incorrect = self
.sync_layer
.check_simulation_consistency(self.disconnect_frame);
// if we have an incorrect frame, then we need to rollback
if first_incorrect != NULL_FRAME {
self.adjust_gamestate(first_incorrect, confirmed_frame, &mut requests);
self.disconnect_frame = NULL_FRAME;
}

let last_saved = self.sync_layer.last_saved_frame();
if self.sparse_saving {
self.check_last_saved_state(last_saved, confirmed_frame, &mut requests);
} else {
// without sparse saving, always save the current frame after correcting and rollbacking
requests.push(self.sync_layer.save_current_state());
// request gamestate save of current frame
let last_saved = self.sync_layer.last_saved_frame();
if self.sparse_saving {
self.check_last_saved_state(last_saved, confirmed_frame, &mut requests);
} else {
// without sparse saving, always save the current frame after correcting and rollbacking
requests.push(self.sync_layer.save_current_state());
}
}

/*
Expand Down Expand Up @@ -371,10 +390,22 @@ impl<T: Config> P2PSession<T> {
* ADVANCE THE STATE
*/

let frames_ahead = self.sync_layer.current_frame() - self.sync_layer.last_confirmed_frame();
if self.sync_layer.current_frame() < self.max_prediction as i32
|| frames_ahead <= self.max_prediction as i32
{
let can_advance = if lockstep {
// lockstep mode: only advance if the current frame has inputs confirmed from all other
// players.
self.sync_layer.last_confirmed_frame() == self.sync_layer.current_frame()
} else {
// rollback mode: advance as long as we aren't past our prediction window
let frames_ahead = if self.sync_layer.last_confirmed_frame() == NULL_FRAME {
// we haven't had any frames confirmed, so all frames we've advanced are "ahead"
self.sync_layer.current_frame()
} else {
// we're not at the first frame, so we have to subtract the last confirmed frame
self.sync_layer.current_frame() - self.sync_layer.last_confirmed_frame()
};
frames_ahead < self.max_prediction as i32
};
if can_advance {
// get correct inputs for the current frame
let inputs = self
.sync_layer
Expand Down Expand Up @@ -531,6 +562,14 @@ impl<T: Config> P2PSession<T> {
self.max_prediction
}

/// Returns true if the session is running in lockstep mode.
///
/// In lockstep mode, a session will only advance if the current frame has inputs confirmed from
/// all other players.
pub fn in_lockstep_mode(&mut self) -> bool {
self.max_prediction == 0
}

/// Returns the current [`SessionState`] of a session.
pub fn current_state(&self) -> SessionState {
self.state
Expand Down
35 changes: 19 additions & 16 deletions src/sync_layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,29 +143,22 @@ impl<'c, T> GameStateAccessor<'c, T> {

pub(crate) struct SavedStates<T> {
pub states: Vec<GameStateCell<T>>,
max_pred: usize,
}

impl<T> SavedStates<T> {
fn new(max_pred: usize) -> Self {
let mut states = Vec::with_capacity(max_pred);
for _ in 0..max_pred {
// we need to store the current frame plus the number of max predictions, so that we can
// roll back to the very first frame even when we have predicted as far ahead as we can.
let num_cells = max_pred + 1;
let mut states = Vec::with_capacity(num_cells);
for _ in 0..num_cells {
states.push(GameStateCell::default());
}

// if lockstep, we still provide a single cell for saving.
if max_pred == 0 {
states.push(GameStateCell::default());
}

Self { states, max_pred }
Self { states }
}

fn get_cell(&self, frame: Frame) -> GameStateCell<T> {
// if lockstep, we still provide a single cell for saving.
if self.max_pred == 0 {
return self.states[0].clone();
}
assert!(frame >= 0);
let pos = frame as usize % self.states.len();
self.states[pos].clone()
Expand Down Expand Up @@ -235,10 +228,20 @@ impl<T: Config> SyncLayer<T> {
/// Loads the gamestate indicated by `frame_to_load`.
pub(crate) fn load_frame(&mut self, frame_to_load: Frame) -> GgrsRequest<T> {
// The state should not be the current state or the state should not be in the future or too far away in the past
assert!(frame_to_load != NULL_FRAME, "cannot load null frame");
assert!(
frame_to_load < self.current_frame,
"must load frame in the past (frame to load is {}, current frame is {})",
frame_to_load,
self.current_frame
);
assert!(
frame_to_load != NULL_FRAME
&& frame_to_load < self.current_frame
&& frame_to_load >= self.current_frame - self.max_prediction as i32
frame_to_load >= self.current_frame - self.max_prediction as i32,
"cannot load frame outside of prediction window; \
(frame to load is {}, current frame is {}, max prediction is {})",
frame_to_load,
self.current_frame,
self.max_prediction
);

let cell = self.saved_states.get_cell(frame_to_load);
Expand Down

0 comments on commit c2814a1

Please sign in to comment.