Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ In this document, all notable changes are listed, including bug fixes, breaking
- breaking: `NetworkStats::kbps_sent` has been removed; `network_stats()` now reports queue length, RTT, and frame-advantage data only

### Bug fixes
- fix: lockstep mode (`max_prediction = 0`) now requires only one-way latency worth of input delay instead of round-trip; `advance_frame` now unconditionally advances the input queue (keeping packets flowing) while the game frame only steps forward when all players have confirmed inputs, so a single network one-way trip is sufficient to confirm and advance each game frame (fixes [#116](https://github.com/gschup/ggrs/issues/116))
- fix: lockstep mode (`max_prediction = 0`) now keeps `current_frame()` aligned with emitted game frames and no longer advances an internal input queue on stalls; `advance_frame` remains conservative and may require enough input delay to cover polling/scheduling jitter, while the new `P2PSession::advance_frame_with_wait` helper can poll briefly before returning an empty request list (follow-up for [#116](https://github.com/gschup/ggrs/issues/116))
- fix: desync detection no longer panics with sparse saving when the configured checksum interval frame was not saved exactly (fixes [#107](https://github.com/gschup/ggrs/issues/107))
- fix: malformed input packets are now discarded with warnings instead of panicking when connection status lengths, start frames, compressed payloads, or decoded player input shapes are invalid
- fix: increasing input delay mid-session now sends generated gap-fill inputs to remote peers, and the first outgoing packet from a peer using input delay is no longer incorrectly rejected; both issues would freeze the session
Expand All @@ -20,6 +20,7 @@ In this document, all notable changes are listed, including bug fixes, breaking

### Improvements
- feat: `P2PSession::set_input_delay()` allows changing the input delay for a local player mid-session (closes [#106](https://github.com/gschup/ggrs/issues/106))
- feat: `P2PSession::advance_frame_with_wait()` and `advance_frame_with_wait_timeout()` provide an explicit bounded wait for lockstep stalls, reducing the need for users to hand-roll a busy polling loop

### Documentation
- docs: refreshed session setup and runtime guidance for builder validation, runtime input-delay changes, spectator catch-up settings, network stats warmup errors, and checksum-based desync detection
Expand Down
16 changes: 11 additions & 5 deletions docs/main-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,28 +60,34 @@ time_since_last_frame += last_tick.elapsed();
last_tick = Instant::now();

while time_since_last_frame >= fps_delta {
time_since_last_frame -= fps_delta;

// add input once for each local player handle
session.add_local_input(local_handle, current_input)?;

match session.advance_frame() {
Ok(requests) => {
if requests.is_empty() && session.in_lockstep_mode() {
// lockstep stall: no game frame was emitted, so keep the
// accumulated time and retry after more network polling
break;
}

time_since_last_frame -= fps_delta;
for request in requests {
handle_ggrs_request(request);
}
// In lockstep mode, requests may be empty — remote inputs have not
// arrived yet. This is not an error; the session will advance on the
// next tick once poll_remote_clients delivers the missing inputs.
}
Err(GgrsError::PredictionThreshold) => {
time_since_last_frame -= fps_delta;
// remote peer is too far behind; skip this frame (rollback mode only)
}
Err(e) => return Err(e),
}
}
```

For lockstep mode, `P2PSession::advance_frame_with_wait()` can reduce poll-phase stalls by polling
for a bounded amount of time before returning an empty request list.

## Time Synchronization

When your client runs ahead of remote peers, you accumulate one-sided rollbacks. GGRS provides two tools to compensate:
Expand Down
4 changes: 2 additions & 2 deletions docs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ let mut session = SessionBuilder::<GgrsConfig>::new()
|---|---|---|
| `with_num_players(n)` | 2 | Total number of players (not counting spectators). Must be at least 1. Revalidates already-added handles. |
| `with_fps(fps)` | 60 | Expected update frequency. Used for frame synchronization heuristics. |
| `with_input_delay(n)` | 0 | Frames of artificial delay applied to local input. Reduces rollbacks in rollback mode. In lockstep mode, set this to at least `ceil(one_way_latency_in_frames)` so remote inputs arrive before they are needed. |
| `with_max_prediction_window(n)` | 8 | Maximum frames GGRS will predict ahead. Set to `0` for lockstep mode: no prediction, no rollbacks, game stalls until all remote inputs are confirmed. |
| `with_input_delay(n)` | 0 | Frames of artificial delay applied to local input. Reduces rollbacks in rollback mode. In lockstep mode, this schedules local input ahead while the public session frame remains the game frame. |
| `with_max_prediction_window(n)` | 8 | Maximum frames GGRS will predict ahead. Set to `0` for conservative lockstep mode: no prediction, no rollbacks, game stalls until all remote inputs are confirmed. Use `P2PSession::advance_frame_with_wait` for a bounded wait that can reduce poll-phase stalls. |
| `with_sparse_saving_mode(bool)` | false | Only save state at the last confirmed frame. See [Sparse Saving](sparse-saving.md). |
| `with_desync_detection_mode(mode)` | Off | Enable checksum-based desync detection. `DesyncDetection::On` requires an interval higher than 0. See [`DesyncDetection`](https://docs.rs/ggrs/latest/ggrs/enum.DesyncDetection.html). |
| `with_disconnect_timeout(duration)` | 2s | How long without packets before a remote peer is disconnected. |
Expand Down
11 changes: 9 additions & 2 deletions examples/ex_game/ex_game_p2p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// if enough time is accumulated, we run a frame
while accumulator.as_secs_f64() > fps_delta {
// decrease accumulator
accumulator = accumulator.saturating_sub(Duration::from_secs_f64(fps_delta));
let frame_duration = Duration::from_secs_f64(fps_delta);
accumulator = accumulator.saturating_sub(frame_duration);

// frames are only happening if the sessions are synchronized
if sess.current_state() == SessionState::Running {
Expand All @@ -129,7 +130,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}

match sess.advance_frame() {
Ok(requests) => game.handle_requests(requests),
Ok(requests) => {
if requests.is_empty() && sess.in_lockstep_mode() {
accumulator = accumulator.saturating_add(frame_duration);
break;
}
game.handle_requests(requests);
}
Err(e) => return Err(Box::new(e)),
}
}
Expand Down
4 changes: 0 additions & 4 deletions src/input_queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,6 @@ impl<T: Config> InputQueue<T> {
self.first_incorrect_frame
}

pub(crate) fn frame_delay(&self) -> usize {
self.frame_delay
}

/// Changes the frame delay and returns any fill inputs that were implicitly added to bridge the
/// gap. The caller is responsible for sending these to remote peers so they see consecutive
/// frame numbers.
Expand Down
24 changes: 14 additions & 10 deletions src/sessions/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,13 @@ impl<T: Config> SessionBuilder<T> {
/// - `AdvanceFrame` is only emitted once all remote inputs for the current frame are confirmed.
/// - `SaveGameState` and `LoadGameState` are never emitted — no rollback occurs.
///
/// In lockstep mode the game stalls until remote inputs arrive, so smooth playback requires
/// enough input delay to cover the one-way network trip. Set
/// `with_input_delay(ceil(one_way_latency_in_frames))` — roughly half the round-trip time.
/// The semantics match rollback mode: input pressed on frame `F` takes effect on frame `F + D`.
/// Plain lockstep mode is conservative: the game stalls until remote inputs are observed by
/// the next call to [`P2PSession::advance_frame`]. Smooth playback may therefore require input
/// delay that covers network latency plus polling/scheduling jitter. For a bounded wait that
/// polls during the current tick, use [`P2PSession::advance_frame_with_wait`].
///
/// Input-delay semantics match rollback mode: input pressed on frame `F` takes effect on frame
/// `F + D`.
pub fn with_max_prediction_window(mut self, window: usize) -> Self {
self.max_prediction = window;
self
Expand All @@ -162,14 +165,14 @@ impl<T: Config> SessionBuilder<T> {
/// In rollback mode, input delay (typically 2–4 frames) reduces rollbacks by letting remote
/// inputs arrive before they are needed, at the cost of constant perceived latency.
///
/// In lockstep mode, input delay determines how far ahead the input queue runs relative to
/// the confirmed game frame. Set this to at least `ceil(one_way_latency_in_frames)` —
/// roughly half the round-trip time — so remote inputs arrive before the game needs them
/// and the session can advance without stalling.
/// In lockstep mode, input delay determines how far ahead local input is scheduled, but the
/// public session frame remains the game frame. Without [`P2PSession::advance_frame_with_wait`],
/// smooth playback may require enough delay to cover network latency plus polling/scheduling
/// jitter.
///
/// There is no enforced upper bound, but values above ~8 frames will produce noticeable
/// input lag. Setting this higher than `max_prediction_window` is not recommended —
/// inputs delayed beyond the prediction window will stall the session.
/// input lag. In rollback mode, setting this higher than `max_prediction_window` is not
/// recommended because inputs delayed beyond the prediction window will stall the session.
pub fn with_input_delay(mut self, delay: usize) -> Self {
self.input_delay = delay;
self
Expand Down Expand Up @@ -372,6 +375,7 @@ impl<T: Config> SessionBuilder<T> {
self.sparse_saving,
self.desync_detection,
self.input_delay,
self.fps,
))
}

Expand Down
Loading
Loading