Migration Guide: ggrs → fortress-rollback¶
Fortress Rollback is the correctness-first, verified fork of the original ggrs crate. This guide explains how to migrate existing projects.
TL;DR¶
- Update your dependency to
fortress-rollbackand change Rust imports tofortress_rollback. - Ensure your
Config::Addresstype implementsOrd+PartialOrd(in addition toClone + Eq + Hash). - Rename types:
GgrsError→FortressError,GgrsEvent→FortressEvent,GgrsRequest→FortressRequest. - All examples/tests now import
fortress_rollback; mirror that pattern in your code. - New in Unreleased: runtime input-delay adjustment (
set_input_delay/input_delay), opt-in graceful peer drop (DisconnectBehavior::ContinueWithout,with_disconnect_behavior), and explicit graceful removal (remove_player); exhaustive matches onFortressEvent,InvalidRequestKind, andInternalErrorKindneed new arms — see Unreleased section.
Dependency Changes¶
# Before
[dependencies]
ggrs = "0.11"
# After
[dependencies]
fortress-rollback = "0.8" # current version
If you were using a git/path dependency, point it to the new repository:
fortress-rollback = { git = "https://github.com/wallstop/fortress-rollback", branch = "main" }
# or
fortress-rollback = { path = "../fortress-rollback" }
Import Path Changes¶
- use ggrs::{SessionBuilder, P2PSession};
+ use fortress_rollback::{SessionBuilder, P2PSession};
Type Renames (Breaking Change)¶
All Ggrs* types have been renamed to Fortress* for consistency:
// Before
use ggrs::{GgrsError, GgrsEvent, GgrsRequest};
// After
use fortress_rollback::{FortressError, FortressEvent, FortressRequest};
| Old Name | New Name |
|---|---|
GgrsError |
FortressError |
GgrsEvent<T> |
FortressEvent<T> |
GgrsRequest<T> |
FortressRequest<T> |
Update your pattern matching accordingly:
// Before
match request {
GgrsRequest::SaveGameState { cell, frame } => { ... }
GgrsRequest::LoadGameState { cell, frame } => { ... }
GgrsRequest::AdvanceFrame { inputs } => { ... }
}
// After
match request {
FortressRequest::SaveGameState { cell, frame } => { ... }
FortressRequest::LoadGameState { cell, frame } => { ... }
FortressRequest::AdvanceFrame { inputs } => { ... }
}
Result Type Alias Rename¶
The Result type alias has been renamed to FortressResult to avoid shadowing
the standard library's Result when using glob imports:
// Before
use fortress_rollback::Result;
fn my_function() -> Result<()> { ... }
// After (option 1: use the new name directly)
use fortress_rollback::FortressResult;
fn my_function() -> FortressResult<()> { ... }
// After (option 2: local alias if you prefer short names)
use fortress_rollback::FortressResult as Result;
fn my_function() -> Result<()> { ... }
Input Vector Type Change (Breaking Change)¶
The inputs field in FortressRequest::AdvanceFrame now uses InputVec<T::Input> (a SmallVec)
instead of Vec<(T::Input, InputStatus)>. This avoids heap allocations for games with 1-4 players.
Most code will work unchanged since InputVec implements Deref<Target = [(T::Input, InputStatus)]>:
// These all work unchanged:
for (input, status) in inputs.iter() { ... }
let first_input = inputs[0];
let len = inputs.len();
If you explicitly typed the inputs as Vec, update the signature:
// Before
fn process_inputs(inputs: Vec<(MyInput, InputStatus)>) { ... }
// After (two options)
use fortress_rollback::InputVec;
// Option 1: Use InputVec directly
fn process_inputs(inputs: InputVec<MyInput>) { ... }
// Option 2: Accept any slice-like type (most flexible)
fn process_inputs(inputs: &[(MyInput, InputStatus)]) { ... }
// Option 3: Convert to Vec if needed (allocates)
fn process_inputs(inputs: impl Into<Vec<(MyInput, InputStatus)>>) {
let inputs = inputs.into_iter().collect::<Vec<_>>();
...
}
The InputVec type alias is re-exported for convenience:
Address Trait Bounds (Breaking Change)¶
Config::Address now requires Ord + PartialOrd so deterministic collections can be used internally.
Most standard address types already satisfy this. For custom types, derive the traits:
Input Trait Bounds (Breaking Change)¶
Config::Input now requires Eq in addition to PartialEq. This ensures reflexive
equality for deterministic rollback; non-reflexive types (e.g., f32, f64) would cause
phantom prediction misses because NaN != NaN can make the engine treat identical inputs
as different, triggering unnecessary rollbacks.
Most custom input types only need an extra derive:
// Before
#[derive(Copy, Clone, PartialEq, Default, Serialize, Deserialize)]
struct MyInput {
buttons: u8,
stick_x: i8,
}
// After
#[derive(Copy, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
struct MyInput {
buttons: u8,
stick_x: i8,
}
Note: All primitive integer types (
u8,i8,u16,i16,u32,i32,u64,i64,u128,i128,usize,isize) andboolalready implementEq, so input structs composed entirely of these types only need the added derive.
Features¶
The sync-send feature flag remains compatible. Fortress Rollback adds several new features:
| Feature | Description | New in Fortress |
|---|---|---|
sync-send |
Multi-threaded trait bounds | ❌ (existing) |
tokio |
Async Tokio UDP socket adapter | ✅ |
json |
JSON serialization for telemetry types | ✅ |
paranoid |
Runtime invariant checking | ✅ |
loom |
Concurrency testing | ✅ |
z3-verification |
Formal verification tests | ✅ |
graphical-examples |
Interactive demos | ✅ |
Note: The
jsonfeature enablesto_json()andto_json_pretty()methods on telemetry types. Without this feature, theserde_jsondependency is not included, reducing the default dependency count.
For detailed feature documentation, see the User Guide.
What Stayed the Same¶
- Request-driven API shape (Save/Load/Advance requests)
- Session types (
P2PSession,SpectatorSession,SyncTestSession) - Safe Rust guarantee (
#![forbid(unsafe_code)])
What Improved¶
- Deterministic maps (no
HashMapiteration order issues) - Correctness-first positioning with ongoing formal verification work
- Documentation and branding aligned with the new name
- Consistent naming with
Fortress*prefix on all public types
New Configuration APIs¶
Fortress Rollback introduces structured configuration structs that replace scattered builder methods:
Network Configuration Structs¶
use fortress_rollback::{SyncConfig, ProtocolConfig, TimeSyncConfig, SpectatorConfig, InputQueueConfig};
// Before: Limited configuration options
let builder = SessionBuilder::<MyConfig>::new()
.with_fps(60)?
.with_input_delay(2);
// After: Rich, preset-based configuration
let builder = SessionBuilder::<MyConfig>::new()
.with_fps(60)?
.with_input_delay(2)?
.with_sync_config(SyncConfig::high_latency())
.with_protocol_config(ProtocolConfig::competitive())
.with_time_sync_config(TimeSyncConfig::responsive())
.with_spectator_config(SpectatorConfig::fast_paced())
.with_input_queue_config(InputQueueConfig::high_latency());
SaveMode Enum¶
use fortress_rollback::SaveMode;
// Before (deprecated)
builder.with_sparse_saving_mode(true);
// After (preferred)
builder.with_save_mode(SaveMode::Sparse);
Violation Observer¶
Monitor internal specification violations:
use fortress_rollback::telemetry::CollectingObserver;
use std::sync::Arc;
let observer = Arc::new(CollectingObserver::new());
let builder = SessionBuilder::<MyConfig>::new()
.with_violation_observer(observer.clone());
// After operations, check for violations
if !observer.is_empty() {
for v in observer.violations() {
eprintln!("Violation: {}", v);
}
}
See the User Guide - Complete Configuration Reference for full documentation.
New Desync Detection APIs¶
Fortress Rollback adds new APIs for detecting and monitoring desynchronization:
SyncHealth API¶
The new SyncHealth enum and associated methods provide proper synchronization status checking:
use fortress_rollback::SyncHealth;
// Check sync status with a specific peer
match session.sync_health(peer_handle) {
Some(SyncHealth::InSync) => println!("Synchronized"),
Some(SyncHealth::Pending) => println!("Waiting for checksum data"),
Some(SyncHealth::DesyncDetected { frame, .. }) => {
// Handle desync according to your application's needs
eprintln!("ERROR: Desync detected at frame {frame} — investigation required");
// Application-specific response: could restart session, alert user, etc.
}
None => {} // Not a remote player
}
// Check all peers at once
if session.is_synchronized() {
println!("All peers in sync");
}
// Get the highest verified frame
if let Some(frame) = session.last_verified_frame() {
println!("Verified sync up to frame {}", frame);
}
NetworkStats Checksum Fields¶
NetworkStats now includes desync detection fields:
let stats = session.network_stats(peer_handle)?;
println!("Last compared: {:?}", stats.last_compared_frame);
println!("Checksums match: {:?}", stats.checksums_match);
Important Behavioral Differences¶
Session Termination Pattern¶
⚠️ Warning for GGRS users: If you were using confirmed_frame() or last_confirmed_frame() to determine when to terminate a session, this pattern is incorrect and can lead to subtle bugs.
// ⚠️ WRONG: This was a common GGRS pattern that doesn't work correctly
if session.confirmed_frame() >= target_frames {
break; // Dangerous! Peers may be at different frames!
}
The correct pattern uses the new SyncHealth API:
// ✓ CORRECT: Use sync_health() to verify peer synchronization
if session.confirmed_frame() >= target_frames {
match session.sync_health(peer_handle) {
Some(SyncHealth::InSync) => break, // Safe to exit
Some(SyncHealth::DesyncDetected { frame, .. }) => {
eprintln!("Desync detected at frame {frame:?}");
break; // Exit with error state for application to handle
}
_ => continue, // Keep polling until verified
}
}
See the Session Termination Anti-Pattern section in the User Guide for comprehensive examples, edge cases, and solutions.
Desync Detection Default¶
⚠️ Breaking Change: Desync detection is now enabled by default with DesyncDetection::On { interval: 60 } (once per second at 60fps).
This is a deliberate departure from GGRS, which defaulted to Off. Fortress Rollback enables detection by default because:
- Silent desync is a correctness bug that's extremely difficult to debug
- The overhead is minimal (one checksum comparison per second)
- Early detection prevents subtle multiplayer issues from reaching production
- This aligns with our correctness-first philosophy
If you need to disable desync detection (e.g., for performance benchmarking), explicitly opt out:
use fortress_rollback::DesyncDetection;
let session = SessionBuilder::<GameConfig>::new()
.with_desync_detection_mode(DesyncDetection::Off) // Explicit opt-out
// ...
.start_p2p_session(socket)?;
For tighter detection (e.g., competitive games with anti-cheat needs), reduce the interval:
use fortress_rollback::DesyncDetection;
let session = SessionBuilder::<GameConfig>::new()
.with_desync_detection_mode(DesyncDetection::On { interval: 10 }) // 6 checks/sec at 60fps
// ...
.start_p2p_session(socket)?;
Session Trait (New)¶
Fortress Rollback now provides a unified Session<T> trait implemented by all session types (P2PSession, SpectatorSession, SyncTestSession). This lets you write generic code that works with any session.
This is entirely additive — no migration is required. Existing code using concrete session types continues to work unchanged.
Adopting the Session Trait¶
If you have session-specific game loop code, you can optionally generalize it:
// Before: tied to P2PSession
fn run_frame(session: &mut P2PSession<MyConfig>, input: MyInput) -> FortressResult<()> {
let player = session.local_player_handles()[0];
session.add_local_input(player, input)?;
let requests = session.advance_frame()?;
// handle requests...
Ok(())
}
// After: works with any session type
use fortress_rollback::prelude::*;
fn run_frame<T: Config>(
session: &mut impl Session<T>,
input: T::Input,
) -> FortressResult<()> {
let player = session.local_player_handle_required()?;
session.add_local_input(player, input)?;
let requests = session.advance_frame()?;
// handle requests...
Ok(())
}
Key differences when using the trait:
- Use
session.local_player_handle_required()(returnsResult) instead of indexing intolocal_player_handles() - Use
session.events()to drain events (returns anEventDrainiterator) poll_remote_clients()andcurrent_state()work on all session types (with sensible defaults forSyncTestSession)network_stats()is not on the trait — use it directly onP2PSessionorSpectatorSession
The trait is available in the prelude: use fortress_rollback::prelude::*;
For comprehensive examples including a generic game loop, see the User Guide — Using the Session Trait.
Unreleased: Runtime Input Delay, Disconnect Behavior, and Graceful Peer Removal¶
The forthcoming release introduces three closely related capabilities for P2PSession: runtime input-delay adjustment, configurable disconnect behavior, and explicit graceful peer removal. The features are additive: existing applications that build sessions with SessionBuilder::with_input_delay — see also Understanding Input Delay for the conceptual background — default DisconnectBehavior::Halt, and the legacy disconnect_player continue to work unchanged. The breaking-change implications are limited to exhaustive matches on a small set of public enums.
Backwards compatibility at a glance¶
SessionBuilder::with_disconnect_behaviordefaults toDisconnectBehavior::Halt, which preserves the legacy GGRS-style halt-on-drop semantics. Code that does not callwith_disconnect_behaviorkeeps its current behavior.P2PSession::disconnect_playeris unchanged. The newremove_playeris added alongside it; you only need to migrate toremove_playerif you want graceful drop.P2PSession::set_input_delayis a new method. Existing code that fixes the delay at construction time viawith_input_delaycontinues to work; mid-session adjustment is opt-in.
Breaking-change implications for exhaustive matches¶
FortressEvent, InvalidRequestKind, and InternalErrorKind are not #[non_exhaustive]. Code that exhaustively matches on these enums must add arms for the new variants:
FortressEvent — new variants¶
// Before
match event {
FortressEvent::Synchronizing { .. } => { /* ... */ },
FortressEvent::Synchronized { .. } => { /* ... */ },
FortressEvent::Disconnected { .. } => { /* ... */ },
FortressEvent::NetworkInterrupted { .. } => { /* ... */ },
FortressEvent::NetworkResumed { .. } => { /* ... */ },
FortressEvent::WaitRecommendation { .. } => { /* ... */ },
FortressEvent::DesyncDetected { .. } => { /* ... */ },
FortressEvent::SyncTimeout { .. } => { /* ... */ },
FortressEvent::ReplayDesync { .. } => { /* ... */ },
}
// After
match event {
FortressEvent::Synchronizing { .. } => { /* ... */ },
FortressEvent::Synchronized { .. } => { /* ... */ },
FortressEvent::Disconnected { .. } => { /* ... */ },
FortressEvent::NetworkInterrupted { .. } => { /* ... */ },
FortressEvent::NetworkResumed { .. } => { /* ... */ },
FortressEvent::WaitRecommendation { .. } => { /* ... */ },
FortressEvent::DesyncDetected { .. } => { /* ... */ },
FortressEvent::SyncTimeout { .. } => { /* ... */ },
FortressEvent::ReplayDesync { .. } => { /* ... */ },
// NEW: emitted on graceful drop. Always paired with `Disconnected` in the
// same batch; see User Guide → Disconnect Behavior and Graceful Peer Drop.
FortressEvent::PeerDropped { handle, addr } => {
// Mark the peer as AI-controlled, show "left the game" UI, etc.
let _ = (handle, addr);
},
// NEW: reserved for application-level heuristics. No built-in emitter
// currently produces this event; you may bind-and-ignore
// (`InputDelayRecommendation { .. } => {}`) if you do not consume it.
// Using `_ => {}` would defeat the exhaustive-match check that prompted
// this migration step.
FortressEvent::InputDelayRecommendation {
player_handle,
current_delay,
suggested_delay,
} => {
let _ = (player_handle, current_delay, suggested_delay);
},
}
InvalidRequestKind — new variants¶
// After
match err_kind {
// ... existing variants ...
InvalidRequestKind::InputDelayDecreaseUnsupported { current, requested } => {
eprintln!(
"Cannot lower input delay from {current} to {requested} mid-session"
);
},
InvalidRequestKind::InputDelayMidSessionMultiLocalUnsupported { local_players } => {
eprintln!(
"Mid-session input-delay increase is not supported with {local_players} local players"
);
},
InvalidRequestKind::InputDelayMidSessionPendingOutputFull { delta, capacity } => {
eprintln!(
"Pending-output buffer full: needed {delta} slots, {capacity} available"
);
},
InvalidRequestKind::PlayerAlreadyRemoved { handle } => {
eprintln!("Peer {handle} was already removed; ignoring duplicate request");
},
}
InternalErrorKind — new variant¶
// After
match internal_kind {
// ... existing variants ...
InternalErrorKind::InputQueueGapFillFailed { frame } => {
// Library invariant violation while replicating gap-fill bytes during
// a mid-session input-delay increase. Treat as a bug and report.
eprintln!("internal: input-queue gap-fill failed at frame {frame}");
},
}
If you currently use _ => wildcard arms, no changes are required — but consider replacing the wildcard with explicit arms so future additions are caught at compile time.
Before / After: dynamically adjusting input delay¶
Previously, the only way to change a session's input delay was at construction time, by branching on measured network conditions and choosing a value before calling start_p2p_session. Mid-match adjustments required tearing down and rebuilding the session.
// Before: input delay is fixed for the lifetime of the session.
let session = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(addr), PlayerHandle::new(1))?
.with_input_delay(2)?
.start_p2p_session(socket)?;
// To change the delay, you would have to drop the session and rebuild it.
// After: read the current delay and increase it mid-session in response to
// network conditions. Decreases mid-session are not supported.
const MAX_INPUT_DELAY: usize = 8;
let local = PlayerHandle::new(0);
let stats = session.network_stats(remote_handle)?;
let current = session.input_delay(local)?;
if stats.ping > 120 && current < MAX_INPUT_DELAY {
match session.set_input_delay(local, current.saturating_add(1).min(MAX_INPUT_DELAY)) {
Ok(()) => { /* applied */ },
Err(FortressError::InvalidRequestStructured {
kind: InvalidRequestKind::InputDelayMidSessionPendingOutputFull { .. },
}) => {
// Try again next tick after acknowledgements catch up.
},
Err(other) => return Err(other),
}
}
See the User Guide — Adjusting Input Delay at Runtime for the full constraint list and a complete example.
Before / After: handling a peer disconnect gracefully¶
Previously, the only way to react to a peer disconnect was to observe FortressEvent::Disconnected and tear down the session — P2PSession::disconnect_player did not freeze the input queue, so under default Halt semantics the session simply stopped advancing.
// Before: a disconnect halts the session. There were two ways to react:
//
// 1. Observe `FortressEvent::Disconnected` from a network-driven drop and
// tear down the match.
// 2. Call `disconnect_player(handle)` explicitly when the application
// decided to drop a peer (kick, surrender, etc.). Under default `Halt`
// semantics this also halts the session because `confirmed_frame()`
// stops progressing once the peer is marked disconnected; the input
// queue is **not** frozen and `FortressEvent::PeerDropped` is **not**
// emitted.
session.disconnect_player(handle)?;
for event in session.events() {
if let FortressEvent::Disconnected { addr } = event {
eprintln!("Disconnected from {addr}; tearing down match");
return Ok(());
}
}
// After (option 1): opt in to automatic graceful drop on timeout.
let mut session = SessionBuilder::<GameConfig>::new()
.with_num_players(3)?
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(a1), PlayerHandle::new(1))?
.add_player(PlayerType::Remote(a2), PlayerHandle::new(2))?
.with_disconnect_behavior(DisconnectBehavior::ContinueWithout)
.start_p2p_session(socket)?;
for event in session.events() {
match event {
FortressEvent::PeerDropped { handle, addr } => {
eprintln!("Peer {handle} ({addr}) left; continuing with remaining peers");
},
FortressEvent::Disconnected { .. } => { /* paired event; legacy consumers */ },
_ => {},
}
}
// After (option 2): drop a specific peer immediately (kick / surrender / leave).
match session.remove_player(conceding_remote) {
Ok(()) => {},
Err(FortressError::InvalidRequestStructured {
kind: InvalidRequestKind::PlayerAlreadyRemoved { .. },
}) => {
// Already removed (e.g., a timeout fired first). Treat as a no-op.
},
Err(other) => return Err(other),
}
The legacy disconnect_player is preserved for back-compat. New code should prefer remove_player for graceful drops; see User Guide — Choosing Between disconnect_player and remove_player for the full distinction.
Before / After: handles_by_address now takes &T::Address¶
PlayerRegistry::handles_by_address, PlayerRegistry::handles_by_address_iter, and the P2PSession forwarders now borrow the address rather than taking ownership. Pass &addr instead of addr at every call site.
// Before: address taken by value (cloned at call site for owned variables).
let handles = session.handles_by_address(peer_addr);
for handle in session.handles_by_address_iter(peer_addr.clone()) {
println!("{handle}");
}
// After: address borrowed; no clone required.
let handles = session.handles_by_address(&peer_addr);
for handle in session.handles_by_address_iter(&peer_addr) {
println!("{handle}");
}
This change is mechanical: add a leading & to every call. There are no behavioral changes.
More Information¶
For a complete comparison of features, bug fixes, and improvements, see Fortress vs GGRS.
Reporting Issues¶
Please file new issues on the Fortress Rollback repo: https://github.com/wallstop/fortress-rollback/issues