Fortress Rollback User Guide¶
This guide walks you through integrating Fortress Rollback into your game. By the end, you'll understand how to set up sessions, handle inputs, manage game state, and respond to network events.
Table of Contents¶
- Quick Start
- Defining Your Config
- Setting Up a P2P Session
- Player Handle Convenience Methods
- The Game Loop
- Handling Requests
- Handling Events
- Determinism Requirements
- Network Requirements
- Network Scenario Configuration Guide
- Advanced Configuration
- Feature Flags
- Spectator Sessions
- Testing with SyncTest
- Using the Session Trait
- Common Patterns
- Disconnect Behavior and Graceful Peer Drop
- Common Pitfalls
- Desync Detection and SyncHealth API
- Troubleshooting
Quick Start¶
Here's a minimal example to get you started:
use fortress_rollback::{
Config, FortressRequest, Frame, InputStatus, NonBlockingSocket,
PlayerHandle, PlayerType, SessionBuilder, SessionState,
UdpNonBlockingSocket,
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
// 1. Define your input type
#[derive(Copy, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
struct MyInput {
buttons: u8,
}
// 2. Define your game state (Clone required for rollback, Serialize/Deserialize needed for checksums)
#[derive(Clone, Serialize, Deserialize)]
struct MyGameState {
frame: i32,
player_x: f32,
player_y: f32,
}
// 3. Create your config type
struct MyConfig;
impl Config for MyConfig {
type Input = MyInput;
type State = MyGameState;
type Address = SocketAddr;
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 4. Create a session
let socket = UdpNonBlockingSocket::bind_to_port(7000)?;
let remote_addr: SocketAddr = "127.0.0.1:7001".parse()?;
let mut session = SessionBuilder::<MyConfig>::new()
.with_num_players(2)?
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(remote_addr), PlayerHandle::new(1))?
.start_p2p_session(socket)?;
// 5. Game loop
let mut game_state = MyGameState {
frame: 0,
player_x: 0.0,
player_y: 0.0,
};
loop {
// Poll for network messages
session.poll_remote_clients();
// Only process frames when synchronized
if session.current_state() == SessionState::Running {
// Add local input
// Tip: For cleaner player handle management, see the
// "Player Handle Convenience Methods" section below
let input = MyInput { buttons: 0 }; // Get real input here
session.add_local_input(PlayerHandle::new(0), input)?;
// Advance the frame
for request in session.advance_frame()? {
match request {
FortressRequest::SaveGameState { cell, frame } => {
cell.save(frame, Some(game_state.clone()), None);
}
FortressRequest::LoadGameState { cell, .. } => {
// LoadGameState is only requested for previously saved frames
if let Some(state) = cell.load() {
game_state = state;
}
}
FortressRequest::AdvanceFrame { inputs } => {
// Apply inputs to your game state
game_state.frame += 1;
// ... update game_state based on inputs
}
}
}
}
// Render and sleep...
}
}
📚 See Also — Reduce Boilerplate & Catch Bugs
handle_requests!macro — Eliminate match boilerplate with a concise macrocompute_checksum()— Enable desync detection with built-in deterministic hashing- Config Presets — Use
SyncConfig::lan(),ProtocolConfig::competitive(), etc. for common network conditions- Request Handling Example — Complete example showing both manual matching and macro usage
Defining Your Config¶
The Config trait bundles all type parameters for your session:
use fortress_rollback::Config;
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
// Your input type - sent over the network
#[repr(C)]
#[derive(Copy, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct GameInput {
pub buttons: u8,
pub stick_x: i8,
pub stick_y: i8,
}
// Your game state - saved and loaded during rollback
#[derive(Clone, Serialize, Deserialize)]
pub struct GameState {
pub frame: i32,
pub players: Vec<PlayerState>,
// ... all your game data
}
// Your config type
pub struct GameConfig;
impl Config for GameConfig {
type Input = GameInput;
type State = GameState;
type Address = SocketAddr; // Or your custom address type
}
Input Type Requirements¶
Your input type must:
- Be
Copy + Clone + PartialEq + Eq - Implement
Default(used for disconnected players) - Implement
Serialize + Deserialize(for network transmission)
Tips:
- Keep inputs small; they're sent every frame
- Use bitflags for button states
- Consider
#[repr(C)]for consistent serialization
State Type Requirements¶
Your state type requirements depend on feature flags:
- Default (no
sync-send): No compile-time bounds onState, butCloneis required in practice forGameStateCell::load()during rollback - With
sync-sendfeature:Statemust beClone + Send + Sync
Optional but recommended:
- Implement
Serialize + Deserializefor checksums
Setting Up a P2P Session¶
Use SessionBuilder to configure and create sessions:
use fortress_rollback::{
DesyncDetection, PlayerHandle, PlayerType, SessionBuilder,
UdpNonBlockingSocket,
};
use web_time::Duration;
let socket = UdpNonBlockingSocket::bind_to_port(7000)?;
let remote_addr = "192.168.1.100:7000".parse()?;
let mut session = SessionBuilder::<GameConfig>::new()
// Number of active players (not spectators)
.with_num_players(2)?
// Frames of input delay (reduces rollbacks, adds latency)
.with_input_delay(2)?
// How many frames ahead we can predict
.with_max_prediction_window(8)
// Expected frames per second
.with_fps(60)?
// Enable desync detection (compare checksums every 100 frames)
.with_desync_detection_mode(DesyncDetection::On { interval: 100 })
// Network timeouts
.with_disconnect_timeout(Duration::from_millis(3000))
.with_disconnect_notify_delay(Duration::from_millis(500))
// Add players
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(remote_addr), PlayerHandle::new(1))?
// Start the session
.start_p2p_session(socket)?;
Understanding Input Delay¶
Input delay trades responsiveness for smoothness:
| Delay | Effect |
|---|---|
| 0 | Immediate response, frequent rollbacks |
| 2 | Slight delay, fewer rollbacks |
| 4+ | Noticeable delay, rare rollbacks |
A delay of 2 frames is a good starting point for most games.
Lockstep Mode¶
Set max_prediction_window(0) for lockstep networking:
let session = SessionBuilder::<GameConfig>::new()
.with_max_prediction_window(0) // Lockstep mode
.with_input_delay(0)? // No delay needed
// ...
In lockstep mode:
- No rollbacks ever occur
- No save/load requests
- Frame rate limited by slowest connection
- Good for turn-based or slower-paced games
Player Handle Convenience Methods¶
Once you've added players to a session, you'll often need to work with their handles—for adding inputs, checking player types, or iterating over specific player groups. Fortress Rollback provides convenience methods that make common patterns cleaner and less error-prone.
Two-Player Games (1v1)¶
For the typical 1v1 networked game with one local and one remote player:
# use fortress_rollback::{FortressError, PlayerHandle};
# struct Session;
# impl Session {
# fn local_player_handle_required(&self) -> Result<PlayerHandle, FortressError> { Ok(PlayerHandle::new(0)) }
# fn remote_player_handle_required(&self) -> Result<PlayerHandle, FortressError> { Ok(PlayerHandle::new(1)) }
# fn add_local_input(&mut self, h: PlayerHandle, i: u8) -> Result<(), FortressError> { Ok(()) }
# fn network_stats(&self, h: PlayerHandle) -> Result<Stats, FortressError> { Ok(Stats { ping: 0 }) }
# }
# struct Stats;
# fn get_local_input() -> u8 { 0 }
# fn main() -> Result<(), FortressError> {
# let mut session = Session;
// Get the single local player's handle (returns error if not exactly 1)
let local = session.local_player_handle_required()?;
session.add_local_input(local, get_local_input())?;
// Get the single remote player's handle for stats
let remote = session.remote_player_handle_required()?;
let stats = session.network_stats(remote)?;
# Ok(())
# }
The *_required() methods return an error if there isn't exactly one player of that type, catching configuration mistakes early.
Multi-Player Games¶
For games with multiple local players (couch co-op) or multiple remotes:
# use fortress_rollback::{FortressError, PlayerHandle};
# use fortress_rollback::HandleVec;
# struct Session;
# impl Session {
# fn local_player_handles(&self) -> HandleVec { HandleVec::new() }
# fn remote_player_handles(&self) -> HandleVec { HandleVec::new() }
# fn add_local_input(&mut self, h: PlayerHandle, i: u8) -> Result<(), FortressError> { Ok(()) }
# fn network_stats(&self, h: PlayerHandle) -> Result<Stats, FortressError> { Ok(Stats { ping: 0 }) }
# }
# struct Stats { ping: u32 }
# fn get_input_for_controller(i: usize) -> u8 { 0 }
# fn main() -> Result<(), FortressError> {
# let mut session = Session;
// Add input for all local players (e.g., two controllers on one machine)
for (i, handle) in session.local_player_handles().into_iter().enumerate() {
let input = get_input_for_controller(i);
session.add_local_input(handle, input)?;
}
// Check network stats for all remote players
for handle in session.remote_player_handles() {
let stats = session.network_stats(handle)?;
if stats.ping > 150 {
println!("High latency with player {:?}", handle);
}
}
# Ok(())
# }
Checking Player Types¶
When you need to handle different player types differently:
# use fortress_rollback::{PlayerHandle, PlayerType};
# use std::net::SocketAddr;
# use fortress_rollback::HandleVec;
# struct Session;
# impl Session {
# fn all_player_handles(&self) -> HandleVec { HandleVec::new() }
# fn is_local_player(&self, h: PlayerHandle) -> bool { false }
# fn is_remote_player(&self, h: PlayerHandle) -> bool { false }
# fn is_spectator_handle(&self, h: PlayerHandle) -> bool { false }
# fn player_type(&self, h: PlayerHandle) -> Option<PlayerType<SocketAddr>> { None }
# }
# fn main() {
# let session = Session;
// Iterate all handles and branch by type
for handle in session.all_player_handles() {
if session.is_local_player(handle) {
// Add local input
} else if session.is_remote_player(handle) {
// Show network indicator in UI
} else if session.is_spectator_handle(handle) {
// Show spectator badge
}
}
// Or use player_type() for full details
for handle in session.all_player_handles() {
match session.player_type(handle) {
Some(PlayerType::Local) => println!("Local player: {:?}", handle),
Some(PlayerType::Remote(addr)) => println!("Remote at {}: {:?}", addr, handle),
Some(PlayerType::Spectator(addr)) => println!("Spectator at {}: {:?}", addr, handle),
None => {} // Handle not found
}
}
# }
Counting Players¶
For matchmaking UI or game logic that depends on player counts:
# struct Session;
# impl Session {
# fn num_local_players(&self) -> usize { 1 }
# fn num_remote_players(&self) -> usize { 1 }
# }
# fn main() {
# let session = Session;
let local_count = session.num_local_players();
let remote_count = session.num_remote_players();
if local_count > 1 {
println!("Local co-op mode with {} players", local_count);
}
println!("Connected to {} remote players", remote_count);
# }
SyncTestSession Methods¶
SyncTestSession has similar methods for consistency, though all players are local:
# use fortress_rollback::{FortressError, PlayerHandle, SyncTestSession};
# use std::net::SocketAddr;
# struct TestConfig;
# impl fortress_rollback::Config for TestConfig {
# type Input = u8;
# type State = ();
# type Address = SocketAddr;
# }
# fn main() -> Result<(), FortressError> {
let session: SyncTestSession<TestConfig> =
fortress_rollback::SessionBuilder::new()
.with_num_players(1)?
.start_synctest_session()?;
// Single-player sync test
let handle = session.local_player_handle_required()?;
// Multi-player sync test
let all_handles = session.local_player_handles();
# Ok(())
# }
Method Reference¶
| Method | Returns | Use Case |
|---|---|---|
local_player_handle() |
Option<PlayerHandle> |
First local player (if any) |
local_player_handle_required() |
Result<PlayerHandle> |
Single local player or error |
local_player_handles() |
HandleVec |
All local players |
remote_player_handle() |
Option<PlayerHandle> |
First remote player (if any) |
remote_player_handle_required() |
Result<PlayerHandle> |
Single remote player or error |
remote_player_handles() |
HandleVec |
All remote players |
is_local_player(handle) |
bool |
Check if handle is local |
is_remote_player(handle) |
bool |
Check if handle is remote |
is_spectator_handle(handle) |
bool |
Check if handle is spectator |
spectator_handles() |
HandleVec |
All spectator handles |
player_type(handle) |
Option<PlayerType> |
Full type info for handle |
num_local_players() |
usize |
Count of local players |
num_remote_players() |
usize |
Count of remote players |
all_player_handles() |
HandleVec |
All handles (local + remote + spectators) |
The Game Loop¶
A typical game loop with Fortress Rollback:
use web_time::{Duration, Instant};
const FPS: f64 = 60.0;
let frame_duration = Duration::from_secs_f64(1.0 / FPS);
let mut last_update = Instant::now();
let mut accumulator = Duration::ZERO;
loop {
// 1. Network polling (do this frequently)
session.poll_remote_clients();
// 2. Handle events
for event in session.events() {
handle_event(event);
}
// 3. Fixed timestep accumulator
let now = Instant::now();
accumulator += now - last_update;
last_update = now;
// 4. Adjust for frame advantage (optional, helps sync)
let mut adjusted_duration = frame_duration;
if session.frames_ahead() > 0 {
adjusted_duration = Duration::from_secs_f64(1.0 / FPS * 1.1);
}
// 5. Process frames
while accumulator >= adjusted_duration {
accumulator -= adjusted_duration;
if session.current_state() == SessionState::Running {
// Add input for all local players
for handle in session.local_player_handles() {
let input = get_local_input(handle);
session.add_local_input(handle, input)?;
}
// Advance and handle requests
let requests = session.advance_frame()?;
handle_requests(requests, &mut game_state);
}
}
// 6. Render
render(&game_state);
// 7. Sleep/wait
std::thread::sleep(Duration::from_millis(1));
}
Important: Order Matters¶
- Call
poll_remote_clients()before checking state or adding input - Add input for all local players before calling
advance_frame() - Process all requests in the order received
Handling Requests¶
Requests are returned by advance_frame() and must be processed in order.
💡 Exhaustive Matching — No Wildcard Needed
FortressRequestis not marked#[non_exhaustive], so you can match all variants without a wildcard_ =>arm. The compiler will notify you if new variants are added in future versions, ensuring your code stays up-to-date.
use fortress_rollback::{FortressRequest, RequestVec, compute_checksum};
fn handle_requests(
requests: RequestVec<GameConfig>,
game_state: &mut GameState,
) {
for request in requests {
match request {
FortressRequest::SaveGameState { cell, frame } => {
// Optionally verify frame consistency (use debug_assert in tests only)
debug_assert_eq!(game_state.frame, frame.as_i32());
// Clone your state
let state_copy = game_state.clone();
// Compute a checksum for desync detection
let checksum = compute_checksum(game_state).ok();
// Save it
cell.save(frame, Some(state_copy), checksum);
}
FortressRequest::LoadGameState { cell, frame } => {
// LoadGameState is only requested for previously saved frames.
// Missing state indicates a library bug, but we handle gracefully.
if let Some(loaded) = cell.load() {
*game_state = loaded;
// Optionally verify frame consistency (use debug_assert in tests only)
debug_assert_eq!(game_state.frame, frame.as_i32());
} else {
// This should never happen - log for debugging
eprintln!("WARNING: LoadGameState for frame {frame:?} but no state found");
}
}
FortressRequest::AdvanceFrame { inputs } => {
// Process inputs for all players
for (player_idx, (input, status)) in inputs.iter().enumerate() {
match status {
InputStatus::Confirmed => {
// This input is definitely correct
}
InputStatus::Predicted => {
// This input might be wrong (rollback may follow)
}
InputStatus::Disconnected => {
// Player disconnected; input is default value
// You might want to use AI or freeze this player
}
}
apply_input(game_state, player_idx, *input, *status);
}
// Advance your frame counter
game_state.frame += 1;
}
}
}
}
Using the handle_requests! Macro¶
For simpler cases, you can use the handle_requests! macro to reduce boilerplate:
use fortress_rollback::{
handle_requests, compute_checksum, FortressRequest, Frame, GameStateCell, InputVec, RequestVec,
};
fn handle_requests_simple(
requests: RequestVec<GameConfig>,
game_state: &mut GameState,
) {
handle_requests!(
requests,
save: |cell: GameStateCell<GameState>, frame: Frame| {
let checksum = compute_checksum(game_state).ok();
cell.save(frame, Some(game_state.clone()), checksum);
},
load: |cell: GameStateCell<GameState>, frame: Frame| {
// LoadGameState is only requested for previously saved frames.
// Missing state indicates a library bug, but we handle gracefully.
if let Some(loaded) = cell.load() {
*game_state = loaded;
} else {
eprintln!("WARNING: LoadGameState for frame {frame:?} but no state found");
}
},
advance: |inputs: InputVec<GameInput>| {
for (_input, _status) in inputs.iter() {
// Apply input
}
game_state.frame += 1;
}
);
}
The macro handles all three request types and processes them in order. For lockstep mode (where save/load never occur), you can provide empty handlers:
handle_requests!(
requests,
save: |_, _| { /* Never called in lockstep */ },
load: |_, _| { /* Never called in lockstep */ },
advance: |inputs: InputVec<GameInput>| {
game_state.frame += 1;
}
);
Computing Checksums¶
Checksums enable desync detection. Fortress Rollback provides built-in functions for deterministic checksum computation:
use fortress_rollback::compute_checksum;
use serde::Serialize;
#[derive(Serialize)]
struct GameState {
frame: u32,
players: Vec<Player>,
}
// One-liner checksum computation (returns Result)
let checksum = compute_checksum(&game_state)?;
// Use in SaveGameState handler
cell.save(frame, Some(game_state.clone()), Some(checksum));
The compute_checksum function:
- Serializes your state using bincode with fixed-integer encoding (platform-independent)
- Hashes the bytes using FNV-1a (deterministic, no random seeds)
- Returns a
u128checksum matching thecell.save()signature
Alternative: Fletcher-16¶
For a faster but weaker checksum, use compute_checksum_fletcher16:
use fortress_rollback::compute_checksum_fletcher16;
// Faster, simpler checksum (16-bit result stored as u128)
let checksum = compute_checksum_fletcher16(&game_state)?;
Manual Checksumming¶
For advanced use cases, you can compute checksums manually using the lower-level utilities:
use fortress_rollback::checksum::{hash_bytes_fnv1a, fletcher16};
use fortress_rollback::network::codec::encode;
// Serialize state to bytes
let bytes = encode(&game_state)?;
// Hash with FNV-1a (64-bit hash as u128)
let fnv_checksum = hash_bytes_fnv1a(&bytes);
// Or use Fletcher-16 (16-bit checksum)
let fletcher_checksum = u128::from(fletcher16(&bytes));
Note: The
network::codecmodule uses a fixed-integer bincode configuration that ensures deterministic serialization across platforms. This is the same configuration used internally for network messages.
Handling Events¶
Events notify you of network conditions:
use fortress_rollback::FortressEvent;
fn handle_event(event: FortressEvent<GameConfig>) {
match event {
FortressEvent::Synchronizing {
addr,
total,
count,
total_requests_sent,
elapsed_ms,
} => {
println!("Syncing with {}: {}/{}", addr, count, total);
// High total_requests_sent indicates packet loss during sync
if total_requests_sent > count * 2 {
println!("Warning: sync retries detected, possible packet loss");
}
// Monitor sync duration for network quality assessment
if elapsed_ms > 2000 {
println!("Warning: sync taking {}ms", elapsed_ms);
}
}
FortressEvent::Synchronized { addr } => {
println!("Synchronized with {}", addr);
}
FortressEvent::Disconnected { addr } => {
println!("Disconnected from {}", addr);
// Handle disconnection (show UI, pause game, etc.)
}
FortressEvent::NetworkInterrupted { addr, disconnect_timeout } => {
println!(
"Connection to {} interrupted, disconnecting in {}ms",
addr, disconnect_timeout
);
}
FortressEvent::NetworkResumed { addr } => {
println!("Connection to {} resumed", addr);
}
FortressEvent::WaitRecommendation { skip_frames } => {
println!("Recommendation: wait {} frames", skip_frames);
// Optionally slow down to let others catch up
}
FortressEvent::DesyncDetected {
frame,
local_checksum,
remote_checksum,
addr,
} => {
eprintln!(
"DESYNC at frame {} with {}! Local: {}, Remote: {}",
frame, addr, local_checksum, remote_checksum
);
// This is bad! Debug your determinism.
}
FortressEvent::SyncTimeout { addr, elapsed_ms } => {
eprintln!(
"Sync timeout with {} after {}ms",
addr, elapsed_ms
);
// Session continues trying, but you may choose to abort
}
}
}
Determinism Requirements¶
Rollback networking requires deterministic simulation. The same inputs must always produce the same outputs.
Common Determinism Issues¶
| Issue | Solution |
|---|---|
| Floating-point differences | Use fixed-point math, or be very careful |
| Random numbers | Use seeded RNG, sync seed across clients |
| HashMap iteration order | Use BTreeMap instead |
| System time | Only use frame number, not wall clock |
| Uninitialized memory | Initialize all state |
| Different library versions | Ensure all clients use same code |
Testing Determinism¶
Use SyncTestSession to verify your game is deterministic:
let mut session = SessionBuilder::<GameConfig>::new()
.with_num_players(1)?
.with_check_distance(2) // How many frames to resimulate
.start_synctest_session()?;
// Run your game loop
// Session will rollback every frame and compare checksums
// Mismatches indicate non-determinism!
Network Requirements¶
Rollback networking works best under certain network conditions. Understanding these requirements helps you configure Fortress Rollback appropriately and set player expectations.
Supported Network Conditions¶
| Condition | Supported Range | Optimal | Notes |
|---|---|---|---|
| Round-Trip Time (RTT) | <200ms | <100ms | Higher RTT = more rollbacks |
| Packet Loss | <15% | <5% | Above 15% causes frequent desyncs |
| Jitter | <50ms | <20ms | High jitter causes prediction failures |
| Bandwidth | >56 kbps | >256 kbps | Per-connection requirement |
Condition Effects¶
Low Latency (LAN, <20ms RTT)
- Minimal rollbacks
- Very responsive gameplay
- Use
SyncConfig::lan()preset for faster connection
Medium Latency (Regional, 20-80ms RTT)
- Occasional rollbacks
- Generally smooth gameplay
- Default configuration works well
High Latency (Intercontinental, 80-200ms RTT)
- Frequent rollbacks
- Noticeable input delay recommended (2-3 frames)
- Use
SyncConfig::high_latency()preset - Consider increasing
max_prediction_frames
Very High Latency (>200ms RTT)
- May experience frequent sync failures
- Gameplay quality significantly degraded
- Not recommended for competitive play
Conditions to Avoid¶
| Condition | Problem | Mitigation |
|---|---|---|
| Packet loss >15% | Frequent sync failures, desyncs | Use wired connection, improve network |
| Jitter >50ms | Prediction failures, stuttering | QoS settings, reduce network congestion |
| Asymmetric routes | One player experiences more rollbacks | Cannot mitigate at application level |
| NAT traversal issues | Connection failures | Use STUN/TURN, port forwarding |
| Mobile networks | High variability | WiFi recommended over cellular |
SyncConfig Presets¶
Fortress Rollback provides configuration presets for different network scenarios:
use fortress_rollback::{SessionBuilder, SyncConfig};
// Default: Balanced for typical internet connections
let session = SessionBuilder::<GameConfig>::new()
.with_sync_config(SyncConfig::default())
// ...
// LAN: Fast connection for local networks
let session = SessionBuilder::<GameConfig>::new()
.with_sync_config(SyncConfig::lan())
// ...
// High Latency: More tolerant for 100-200ms RTT connections
let session = SessionBuilder::<GameConfig>::new()
.with_sync_config(SyncConfig::high_latency())
// ...
// Lossy: More retries for 5-15% packet loss environments
let session = SessionBuilder::<GameConfig>::new()
.with_sync_config(SyncConfig::lossy())
// ...
// Mobile: High tolerance for variable mobile networks
let session = SessionBuilder::<GameConfig>::new()
.with_sync_config(SyncConfig::mobile())
// ...
// Competitive: Fast sync with strict timeouts
let session = SessionBuilder::<GameConfig>::new()
.with_sync_config(SyncConfig::competitive())
// ...
Preset Comparison:
| Preset | Sync Packets | Retry Interval | Timeout | Best For |
|---|---|---|---|---|
default() |
5 | 200ms | None | General internet play |
lan() |
3 | 100ms | 5s | LAN parties, localhost |
high_latency() |
5 | 400ms | 10s | Intercontinental, WiFi |
lossy() |
8 | 200ms | 10s | Unstable connections |
mobile() |
10 | 350ms | 15s | Mobile/cellular networks |
competitive() |
4 | 100ms | 3s | Esports, tournaments |
extreme() |
20 | 250ms | 30s | Extreme burst loss, hostile networks |
stress_test() |
40 | 150ms | 60s | Automated testing only (not for production) |
Network Scenario Configuration Guide¶
This section provides complete, production-ready configurations for different network scenarios. Each configuration is designed to optimize the balance between responsiveness and stability for its target environment.
LAN / Local Network (< 20ms RTT)¶
Best for: LAN parties, local tournaments, same-building connections.
Characteristics:
- Ultra-low latency (~1-20ms RTT)
- No packet loss
- Extremely stable connection
use fortress_rollback::{
DesyncDetection, ProtocolConfig, SessionBuilder, SyncConfig,
TimeSyncConfig,
};
use web_time::Duration;
let session = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
// Zero input delay - immediate response
.with_input_delay(0)?
// Minimal prediction needed
.with_max_prediction_window(4)
// LAN presets for fast sync
.with_sync_config(SyncConfig::lan())
.with_protocol_config(ProtocolConfig::competitive())
.with_time_sync_config(TimeSyncConfig::lan())
// Fast disconnect detection (1 second)
.with_disconnect_timeout(Duration::from_millis(1000))
.with_disconnect_notify_delay(Duration::from_millis(200))
// Frequent desync checks (cheap on LAN)
.with_desync_detection_mode(DesyncDetection::On { interval: 60 })
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(remote_addr), PlayerHandle::new(1))?
.start_p2p_session(socket)?;
Why these settings:
input_delay(0): With <20ms RTT, inputs arrive before next framemax_prediction_window(4): Small window since predictions rarely wrongSyncConfig::lan(): 3 sync packets, 100ms retry (fast handshake)TimeSyncConfig::lan(): 10-frame window (faster adaptation)disconnect_timeout(1000ms): Fast detection acceptable on stable network
Regional Internet (20-80ms RTT)¶
Best for: Same-country connections, good home internet, regional matchmaking.
Characteristics:
- Low-moderate latency (20-80ms RTT)
- Occasional packet loss (<2%)
- Generally stable
use fortress_rollback::{
DesyncDetection, ProtocolConfig, SessionBuilder, SyncConfig,
TimeSyncConfig,
};
use web_time::Duration;
let session = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
// Light input delay to reduce rollbacks
.with_input_delay(2)?
// Standard prediction window
.with_max_prediction_window(8)
// Default presets work well for regional
.with_sync_config(SyncConfig::default())
.with_protocol_config(ProtocolConfig::default())
.with_time_sync_config(TimeSyncConfig::default())
// Standard disconnect handling
.with_disconnect_timeout(Duration::from_millis(2500))
.with_disconnect_notify_delay(Duration::from_millis(500))
// Regular desync checks
.with_desync_detection_mode(DesyncDetection::On { interval: 100 })
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(remote_addr), PlayerHandle::new(1))?
.start_p2p_session(socket)?;
Why these settings:
input_delay(2): Masks ~33ms of latency, reduces most rollbacksmax_prediction_window(8): Handles typical jitter spikesSyncConfig::default(): Balanced 5-packet handshake
High Latency (80-200ms RTT)¶
Best for: Intercontinental connections, WiFi on congested networks, mobile hotspots.
Characteristics:
- High latency (80-200ms RTT)
- Moderate packet loss (2-5%)
- Variable jitter
use fortress_rollback::{
DesyncDetection, ProtocolConfig, SaveMode, SessionBuilder, SyncConfig,
TimeSyncConfig,
};
use web_time::Duration;
let session = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
// Higher input delay to reduce rollback frequency
.with_input_delay(4)?
// Large prediction window for latency spikes
.with_max_prediction_window(12)
// High-latency presets with longer intervals
.with_sync_config(SyncConfig::high_latency())
.with_protocol_config(ProtocolConfig::high_latency())
.with_time_sync_config(TimeSyncConfig::smooth())
// Generous disconnect handling
.with_disconnect_timeout(Duration::from_millis(5000))
.with_disconnect_notify_delay(Duration::from_millis(2000))
// Less frequent desync checks (reduce overhead)
.with_desync_detection_mode(DesyncDetection::On { interval: 150 })
// Consider sparse saving if rollbacks are long
.with_save_mode(SaveMode::EveryFrame)
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(remote_addr), PlayerHandle::new(1))?
.start_p2p_session(socket)?;
Why these settings:
input_delay(4): Masks ~67ms, smooths out most high-latency playmax_prediction_window(12): Handles 200ms RTT without stallingSyncConfig::high_latency(): 400ms retry intervals prevent floodingTimeSyncConfig::smooth(): 60-frame window prevents oscillationdisconnect_timeout(5000ms): Tolerates temporary connection hiccups
Tip: Display the input delay to players so they understand the tradeoff:
// UI hint: "Input delay: 4 frames (~67ms) for smoother gameplay"
let input_delay_ms = input_delay_frames * (1000 / fps);
Lossy Network (5-15% Packet Loss)¶
Best for: WiFi with interference, congested networks, some cellular connections.
Characteristics:
- Variable latency
- Significant packet loss (5-15%)
- Packet reordering common
use fortress_rollback::{
DesyncDetection, InputQueueConfig, ProtocolConfig, SessionBuilder,
SyncConfig, TimeSyncConfig,
};
use web_time::Duration;
let session = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
// Moderate input delay
.with_input_delay(3)?
// Large prediction window to handle dropped packets
.with_max_prediction_window(15)
// Lossy preset with extra sync packets
.with_sync_config(SyncConfig::lossy())
.with_protocol_config(ProtocolConfig::default())
.with_time_sync_config(TimeSyncConfig::smooth())
// Large input queue for buffering
.with_input_queue_config(InputQueueConfig::high_latency())
// Very generous disconnect handling
.with_disconnect_timeout(Duration::from_millis(6000))
.with_disconnect_notify_delay(Duration::from_millis(2500))
// Frequent desync checks (packet loss can cause desyncs)
.with_desync_detection_mode(DesyncDetection::On { interval: 60 })
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(remote_addr), PlayerHandle::new(1))?
.start_p2p_session(socket)?;
Why these settings:
SyncConfig::lossy(): 8 sync packets ensures reliable handshakemax_prediction_window(15): Tolerates multiple consecutive dropped packetsInputQueueConfig::high_latency(): 256-frame buffer handles burstsDesyncDetection::On { interval: 60 }: Catches drift from lost packets early
Warning: If packet loss exceeds 15%, rollback networking becomes impractical. Consider showing a network quality warning to users.
Competitive/Tournament (Strict Requirements)¶
Best for: Tournament play, ranked matches, esports.
Characteristics:
- Requires <100ms RTT for fair play
- Zero tolerance for cheating
- Fastest possible response time
use fortress_rollback::{
DesyncDetection, ProtocolConfig, SessionBuilder, SyncConfig,
TimeSyncConfig,
};
use web_time::Duration;
let session = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
// Minimal input delay for competitive edge
.with_input_delay(1)?
// Moderate prediction window
.with_max_prediction_window(6)
// Competitive presets
.with_sync_config(SyncConfig::lan()) // Fast sync even online
.with_protocol_config(ProtocolConfig::competitive())
.with_time_sync_config(TimeSyncConfig::responsive())
// Fast disconnect detection
.with_disconnect_timeout(Duration::from_millis(1500))
.with_disconnect_notify_delay(Duration::from_millis(300))
// Frequent desync detection to catch cheating
.with_desync_detection_mode(DesyncDetection::On { interval: 30 })
// Higher FPS for competitive games
.with_fps(120)?
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(remote_addr), PlayerHandle::new(1))?
.start_p2p_session(socket)?;
Why these settings:
input_delay(1): Minimal delay, accepts more rollbacks for responsivenessDesyncDetection::On { interval: 30 }: Catches cheating attempts quicklyProtocolConfig::competitive(): 100ms quality reports for accurate RTTdisconnect_timeout(1500ms): Quick forfeit on disconnection
Recommendation: Enforce RTT limits in matchmaking:
// Reject matches with >100ms RTT for competitive play
if estimated_rtt_ms > 100 {
return Err("Connection too slow for ranked play");
}
Casual Multiplayer (4+ Players)¶
Best for: Party games, casual online, mixed skill levels.
Characteristics:
- Variable player count (2-8 players)
- Mixed network conditions across players
- Prioritizes stability over responsiveness
use fortress_rollback::{
DesyncDetection, ProtocolConfig, SaveMode, SessionBuilder, SyncConfig,
TimeSyncConfig,
};
use web_time::Duration;
let session = SessionBuilder::<GameConfig>::new()
.with_num_players(4)? // Or up to 8
// Higher input delay for stability with many players
.with_input_delay(3)?
// Large prediction window for worst-case latency
.with_max_prediction_window(12)
// Default presets work for mixed conditions
.with_sync_config(SyncConfig::default())
.with_protocol_config(ProtocolConfig::default())
.with_time_sync_config(TimeSyncConfig::smooth())
// Very lenient disconnect handling
.with_disconnect_timeout(Duration::from_millis(7000))
.with_disconnect_notify_delay(Duration::from_millis(3000))
// Less frequent desync checks (performance with many players)
.with_desync_detection_mode(DesyncDetection::On { interval: 200 })
// Sparse saving helps with performance
.with_save_mode(SaveMode::Sparse)
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(remote_addr1), PlayerHandle::new(1))?
.add_player(PlayerType::Remote(remote_addr2), PlayerHandle::new(2))?
.add_player(PlayerType::Remote(remote_addr3), PlayerHandle::new(3))?
.start_p2p_session(socket)?;
Why these settings:
input_delay(3): Balances stability across varied connectionsTimeSyncConfig::smooth(): Prevents oscillation with many peersSaveMode::Sparse: Reduces save overhead with more playersdisconnect_timeout(7000ms): Gives players time to reconnect
Note: With more players, the slowest connection affects everyone. Consider implementing connection quality indicators per player.
Spectator Streaming¶
Best for: Live event streaming, replay viewers, tournament broadcasts.
Characteristics:
- One-way data flow (host → spectator)
- Spectators may have varied connections
- Acceptable to be slightly behind live play
use fortress_rollback::{
FortressError, PlayerHandle, PlayerType, ProtocolConfig,
SessionBuilder, SpectatorConfig, SyncConfig,
};
use web_time::Duration;
// Host side: P2P session with spectator support
let host_session = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
.with_input_delay(2)?
// Spectator-friendly config: larger buffer for varied viewers
.with_spectator_config(SpectatorConfig {
buffer_size: 180, // 3 seconds at 60 FPS
catchup_speed: 2, // 2x speed when behind
max_frames_behind: 30, // Start catchup at 0.5s behind
..Default::default()
})
.with_sync_config(SyncConfig::default())
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(player2_addr), PlayerHandle::new(1))?
.add_player(PlayerType::Spectator(spectator_addr), PlayerHandle::new(2))?
.start_p2p_session(socket)?;
// Spectator side: uses high-latency tolerant config
let mut spectator_session = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
.with_sync_config(SyncConfig::high_latency())
.with_protocol_config(ProtocolConfig::high_latency())
.with_max_frames_behind(30)?
.with_catchup_speed(2)?
.start_spectator_session(host_addr, spectator_socket)
.ok_or(FortressError::InvalidRequest {
info: "spectator session initialization failed".into(),
})?;
SpectatorConfig presets:
SpectatorConfig::fast_paced(): 90-frame buffer, 2x catchup (action games)SpectatorConfig::slow_connection(): 120-frame buffer, tolerant (streaming)SpectatorConfig::local(): 30-frame buffer, minimal delay (local viewing)
Configuration Decision Tree¶
Use this guide to choose the right configuration:
flowchart TD
START["Start Configuration"] --> RTT1{RTT < 20ms?}
RTT1 -->|Yes| LAN["Use LAN Configuration"]
RTT1 -->|No| RTT2{RTT < 80ms?}
RTT2 -->|Yes| REGIONAL["Use Regional Configuration"]
RTT2 -->|No| RTT3{RTT < 200ms?}
RTT3 -->|Yes| HIGH_LAT["Use High Latency Configuration"]
RTT3 -->|No| WARN["Warn user - connection too slow"]
LAN --> LOSS{Packet loss > 5%?}
REGIONAL --> LOSS
HIGH_LAT --> LOSS
WARN --> END["Configuration Complete"]
LOSS -->|Yes| LOSSY["Also apply Lossy Network Config"]
LOSS -->|No| COMP{Competitive play?}
LOSSY --> COMP
COMP -->|Yes| COMPETITIVE["Use Competitive Config"]
COMP -->|No| PLAYERS{4+ players?}
COMPETITIVE --> PLAYERS
PLAYERS -->|Yes| CASUAL["Use Casual Multiplayer Config"]
PLAYERS -->|No| STANDARD["Use Standard 2-player Config"]
CASUAL --> END
STANDARD --> END
Dynamic Configuration¶
For games with matchmaking, consider adjusting configuration based on measured network conditions:
fn configure_for_network(
rtt_ms: u32,
packet_loss_percent: f32,
) -> Result<SessionBuilder<GameConfig>, FortressError> {
let mut builder = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?;
// Adjust input delay based on RTT
let input_delay = match rtt_ms {
0..=20 => 0,
21..=60 => 1,
61..=100 => 2,
101..=150 => 3,
_ => 4,
};
builder = builder.with_input_delay(input_delay)?;
// Choose sync config based on conditions
let sync_config = if packet_loss_percent > 5.0 {
SyncConfig::lossy()
} else if rtt_ms > 100 {
SyncConfig::high_latency()
} else if rtt_ms < 20 {
SyncConfig::lan()
} else {
SyncConfig::default()
};
builder = builder.with_sync_config(sync_config);
// Adjust prediction window
let prediction_window = match rtt_ms {
0..=50 => 6,
51..=100 => 8,
101..=150 => 10,
_ => 12,
};
builder = builder.with_max_prediction_window(prediction_window);
Ok(builder)
}
Network Quality Monitoring¶
Monitor network quality using the NetworkStats struct returned by session.network_stats(handle):
use fortress_rollback::NetworkStats;
// Check network stats for each remote player
for handle in session.remote_player_handles() {
if let Ok(stats) = session.network_stats(handle) {
let rtt = stats.ping; // Round-trip time in ms
let pending = stats.send_queue_len; // Pending packets
// Warn if conditions are degrading
if rtt > 150 {
println!("Warning: High latency ({}ms) with player {:?}", rtt, handle);
}
if pending > 10 {
println!("Warning: Network congestion with player {:?}", handle);
}
}
}
NetworkStats Fields¶
| Field | Type | Description |
|---|---|---|
ping |
u128 |
Round-trip time in milliseconds |
send_queue_len |
usize |
Number of unacknowledged packets (connection quality indicator) |
kbps_sent |
usize |
Estimated bandwidth usage in kilobits per second |
local_frames_behind |
i32 |
How many frames behind the local client is compared to remote |
remote_frames_behind |
i32 |
How many frames behind the remote client is compared to local |
last_compared_frame |
Option<Frame> |
Most recent frame where checksums were compared |
local_checksum |
Option<u128> |
Local checksum at last_compared_frame |
remote_checksum |
Option<u128> |
Remote checksum at last_compared_frame |
checksums_match |
Option<bool> |
true if synchronized, false if desync detected |
Example: Debug Overlay¶
fn display_network_debug(session: &P2PSession<MyConfig>) {
for handle in session.remote_player_handles() {
if let Ok(stats) = session.network_stats(handle) {
println!("Player {:?}:", handle);
println!(" RTT: {}ms", stats.ping);
println!(" Bandwidth: {} kbps", stats.kbps_sent);
println!(" Send queue: {} packets", stats.send_queue_len);
println!(" Frame diff: local {} behind, remote {} behind",
stats.local_frames_behind, stats.remote_frames_behind);
// Desync status
match stats.checksums_match {
Some(true) => println!(" Sync: ✓ OK"),
Some(false) => println!(" Sync: ✗ DESYNC!"),
None => println!(" Sync: pending"),
}
}
}
}
Sync Failure Troubleshooting¶
If synchronization repeatedly fails:
- Check RTT: If >200ms, use
SyncConfig::high_latency() - Check packet loss: If high, use
SyncConfig::lossy() - Check firewall/NAT: Ensure UDP traffic is allowed
- Monitor sync events: Watch
total_requests_sentandelapsed_ms
FortressEvent::Synchronizing { total_requests_sent, elapsed_ms, .. } => {
// High retry count indicates packet loss
if total_requests_sent > 15 {
println!("Warning: excessive sync retries, check network");
}
// Long sync time indicates high latency
if elapsed_ms > 3000 {
println!("Warning: sync taking {}ms, check connection", elapsed_ms);
}
}
Advanced Configuration¶
Sparse Saving¶
If saving state is expensive, enable sparse saving:
With sparse saving:
- Only saves at confirmed frames
- Fewer save requests
- Potentially longer rollbacks
Custom Sockets¶
Implement NonBlockingSocket for custom networking:
use fortress_rollback::{Message, NonBlockingSocket};
struct MyCustomSocket { /* ... */ }
impl NonBlockingSocket<MyAddress> for MyCustomSocket {
fn send_to(&mut self, msg: &Message, addr: &MyAddress) {
// Serialize and send the message
}
fn receive_all_messages(&mut self) -> Vec<(MyAddress, Message)> {
// Return all received messages since last call
}
}
ChaosSocket for Testing¶
Test network resilience with ChaosSocket:
use fortress_rollback::{ChaosConfig, ChaosSocket, UdpNonBlockingSocket};
let inner_socket = UdpNonBlockingSocket::bind_to_port(7000)?;
let chaos_config = ChaosConfig::builder()
.latency_ms(50) // 50ms base latency
.jitter_ms(20) // +/- 20ms jitter
.packet_loss_rate(0.05) // 5% packet loss
.build();
let socket = ChaosSocket::new(inner_socket, chaos_config);
ChaosConfig Presets¶
ChaosConfig provides several presets for common network scenarios:
| Preset | Latency | Jitter | Loss | Use Case |
|---|---|---|---|---|
passthrough() |
0ms | 0ms | 0% | No chaos (transparent wrapper) |
poor_network() |
100ms | 50ms | 5% | Typical poor connection |
terrible_network() |
250ms | 100ms | 15% | Stress testing, 2% duplication, reordering |
mobile_network() |
60ms | 40ms | 12% | 4G/LTE with burst loss (handoff simulation) |
wifi_interference() |
15ms | 25ms | 3% | Congested WiFi with bursty loss |
intercontinental() |
120ms | 15ms | 2% | Transatlantic/transpacific connections |
Using presets:
use fortress_rollback::{ChaosSocket, ChaosConfig, UdpNonBlockingSocket};
// Test with poor network conditions
let inner = UdpNonBlockingSocket::bind_to_port(7000)?;
let socket = ChaosSocket::new(inner, ChaosConfig::poor_network());
// Test mobile network behavior (burst loss, high jitter)
let inner = UdpNonBlockingSocket::bind_to_port(7001)?;
let mobile_socket = ChaosSocket::new(inner, ChaosConfig::mobile_network());
// Cross-region testing (high stable latency)
let inner = UdpNonBlockingSocket::bind_to_port(7002)?;
let intl_socket = ChaosSocket::new(inner, ChaosConfig::intercontinental());
Custom configurations:
For more control, use the builder pattern with high_latency() or lossy():
use fortress_rollback::ChaosConfig;
use std::time::Duration;
// High latency only
let high_lat = ChaosConfig::high_latency(200); // 200ms latency
// Packet loss only
let lossy = ChaosConfig::lossy(0.10); // 10% symmetric loss
// Fully custom
let custom = ChaosConfig::builder()
.latency(Duration::from_millis(75))
.jitter(Duration::from_millis(25))
.send_loss_rate(0.08)
.receive_loss_rate(0.05)
.duplication_rate(0.01)
.seed(42) // Deterministic for reproducible tests
.build();
ChaosStats¶
ChaosStats provides statistics about ChaosSocket behavior, useful for verifying your test scenarios and debugging network simulation:
use fortress_rollback::{ChaosSocket, ChaosConfig, ChaosStats, UdpNonBlockingSocket};
let inner = UdpNonBlockingSocket::bind_to_port(7000)?;
let mut socket = ChaosSocket::new(inner, ChaosConfig::poor_network());
// ... run your session for a while ...
// Query statistics
let stats: &ChaosStats = socket.stats();
println!("Packets sent: {}", stats.packets_sent);
println!("Packets dropped (send): {}", stats.packets_dropped_send);
println!("Packets dropped (receive): {}", stats.packets_dropped_receive);
println!("Packets duplicated: {}", stats.packets_duplicated);
println!("Packets reordered: {}", stats.packets_reordered);
println!("Burst loss events: {}", stats.burst_loss_events);
// Reset statistics if needed
socket.reset_stats();
| Field | Description |
|---|---|
packets_sent |
Total packets sent through the socket |
packets_dropped_send |
Packets dropped on send |
packets_dropped_receive |
Packets dropped on receive |
packets_duplicated |
Packets duplicated on send |
packets_received |
Total packets received |
packets_reordered |
Packets reordered |
burst_loss_events |
Number of burst loss events triggered |
packets_dropped_burst |
Packets dropped due to burst loss |
Custom Clock (Time Control)¶
Protocol timers -- sync retries, keepalives, disconnect timeouts, quality reports -- all depend on wall-clock time via Instant::now(). In automated tests, especially on slow or loaded CI runners, this causes flakiness: a thread that doesn't get scheduled quickly enough can trigger a spurious disconnect, and tests that rely on thread::sleep() to advance timers are slow and non-deterministic.
The clock abstraction solves this by letting you inject a custom time source into the protocol. Instead of reading the real system clock, the protocol calls your function, and you control when time advances.
Key types:
| Type / Field | Description |
|---|---|
ClockFn |
Arc<dyn Fn() -> Instant + Send + Sync> -- an injectable time source |
ProtocolConfig::clock |
Option<ClockFn> -- when Some, the protocol uses this clock for all timing |
ChaosSocket::with_clock() |
Injects a custom clock into ChaosSocket for deterministic latency simulation |
When clock is None (the default), the protocol uses Instant::now() directly. This is the correct setting for production.
Creating a Controllable Clock¶
A test clock is a shared Instant behind a Mutex that only advances when you tell it to:
use std::sync::{Arc, Mutex};
use web_time::{Duration, Instant};
use fortress_rollback::ClockFn;
// Shared mutable time source
let current_time = Arc::new(Mutex::new(Instant::now()));
// Build a ClockFn that reads from the shared state
let clock_state = Arc::clone(¤t_time);
let clock: ClockFn = Arc::new(move || {
*clock_state.lock().unwrap() // test: panics are acceptable in tests
});
// Advance time by 200ms (replaces thread::sleep)
{
let mut t = current_time.lock().unwrap(); // test: panics are acceptable in tests
*t += Duration::from_millis(200);
}
Injecting into ProtocolConfig¶
Pass the clock when constructing your ProtocolConfig. For full determinism in tests, combine the clock with a fixed RNG seed via deterministic():
use std::sync::{Arc, Mutex};
use web_time::Instant;
use fortress_rollback::{ClockFn, ProtocolConfig};
let current_time = Arc::new(Mutex::new(Instant::now()));
let clock_state = Arc::clone(¤t_time);
let clock: ClockFn = Arc::new(move || {
*clock_state.lock().unwrap() // test: panics are acceptable in tests
});
let protocol_config = ProtocolConfig {
clock: Some(clock),
..ProtocolConfig::deterministic(42) // fixed RNG seed; clock set above
};
// Alternative: use ..ProtocolConfig::default() if you don't need a fixed RNG seed
Injecting into ChaosSocket¶
ChaosSocket has its own clock for timing latency simulation. Use with_clock() to inject a shared time source so that both the protocol and the socket see the same virtual time:
use std::sync::{Arc, Mutex};
use web_time::{Duration, Instant};
use fortress_rollback::{ChaosSocket, ChaosConfig, UdpNonBlockingSocket};
let current_time = Arc::new(Mutex::new(Instant::now()));
// Build ChaosSocket with the same clock
let clock_state = Arc::clone(¤t_time);
let inner = UdpNonBlockingSocket::bind_to_port(7000)?;
let socket = ChaosSocket::new(inner, ChaosConfig::poor_network())
.with_clock(Arc::new(move || {
*clock_state.lock().unwrap() // test: panics are acceptable in tests
}));
Note:
ChaosSocket::with_clock()usesstd::time::Instantinternally. On native platforms, this is the same type asweb_time::Instant, so the same clock function works for both. On WASM targets, these types may differ.
Complete Test Example¶
The following example shows a realistic test pattern that uses a manual clock to advance time deterministically instead of calling thread::sleep():
use std::sync::{Arc, Mutex};
use web_time::{Duration, Instant};
use fortress_rollback::{
ClockFn, ProtocolConfig, ChaosSocket, ChaosConfig,
SessionBuilder, UdpNonBlockingSocket,
};
// 1. Create a shared time source
let current_time = Arc::new(Mutex::new(Instant::now()));
// 2. Build clock functions from the same shared state
let protocol_clock_state = Arc::clone(¤t_time);
let protocol_clock: ClockFn = Arc::new(move || {
*protocol_clock_state.lock().unwrap() // test: panics are acceptable in tests
});
let chaos_clock_state = Arc::clone(¤t_time);
let chaos_clock = Arc::new(move || {
*chaos_clock_state.lock().unwrap() // test: panics are acceptable in tests
});
// 3. Configure the protocol with the virtual clock
let protocol_config = ProtocolConfig {
clock: Some(protocol_clock),
..ProtocolConfig::deterministic(42) // fixed RNG seed; clock set above
};
// 4. Configure ChaosSocket with the same virtual clock
let inner = UdpNonBlockingSocket::bind_to_port(7000)?;
let socket = ChaosSocket::new(inner, ChaosConfig::poor_network())
.with_clock(chaos_clock);
// 5. Build the session with the clock-aware config and socket
let session = SessionBuilder::<MyConfig>::new()
.with_num_players(2)?
.with_protocol_config(protocol_config)
.start_p2p_session(socket)?;
// 6. Run test logic -- advance time explicitly instead of sleeping
// This is instant and deterministic, regardless of CI load:
fn advance(current_time: &Mutex<Instant>, duration: Duration) {
let mut t = current_time.lock().unwrap(); // test: panics are acceptable in tests
*t += duration;
}
// 200ms matches ProtocolConfig's default keepalive/quality_report interval.
// Advancing by this amount triggers timer-driven protocol behavior.
advance(¤t_time, Duration::from_millis(200));
// Simulate 5 seconds passing (might trigger disconnect timeout)
advance(¤t_time, Duration::from_secs(5));
Production Usage¶
In production, leave the clock field as None (the default). The protocol will use Instant::now() for all timing, which is the correct behavior for real-time gameplay:
use fortress_rollback::ProtocolConfig;
// Default config uses the system clock -- correct for production
let config = ProtocolConfig::default();
The clock abstraction is purely additive and non-breaking. Existing code that does not set the clock field continues to work exactly as before.
SessionState¶
SessionState indicates the current state of a P2P or Spectator session:
use fortress_rollback::SessionState;
match session.current_state() {
SessionState::Synchronizing => {
// Still establishing connection with remote peers
// Don't add input or advance frames yet
println!("Waiting for peers to synchronize...");
}
SessionState::Running => {
// Session is fully synchronized and ready for gameplay
// Safe to add local input and advance frames
session.add_local_input(local_handle, input)?;
for request in session.advance_frame()? {
// Handle requests...
}
}
}
| State | Description | Actions Allowed |
|---|---|---|
Synchronizing |
Establishing connection with remote peers | poll_remote_clients() only |
Running |
Fully synchronized, ready for gameplay | All session operations |
Important: Always check session.current_state() == SessionState::Running before calling add_local_input() or advance_frame(). Attempting these operations while synchronizing will return an error.
Prediction Strategies¶
When a remote player's input hasn't arrived yet, Fortress Rollback uses a prediction strategy to guess what input to use. The prediction is later corrected via rollback if wrong.
Two built-in strategies are available:
| Strategy | Behavior | Use Case |
|---|---|---|
RepeatLastConfirmed |
Repeats the player's last confirmed input | Default; good for most games |
BlankPrediction |
Returns the default (blank) input | Games where repeating input is dangerous |
RepeatLastConfirmed (default) assumes players tend to hold inputs for multiple frames, which is true for most games:
use fortress_rollback::RepeatLastConfirmed;
// This is the default behavior - no configuration needed
// The session automatically uses RepeatLastConfirmed
BlankPrediction returns a neutral/default input, useful when repeating the last input could cause unintended actions (e.g., in a game where holding "attack" is dangerous if mispredicted):
use fortress_rollback::BlankPrediction;
// BlankPrediction always returns Input::default()
// Useful for games where "do nothing" is safer than "repeat last action"
Custom Strategies: You can implement the PredictionStrategy trait for game-specific prediction logic:
use fortress_rollback::{Frame, PredictionStrategy};
struct MyPrediction;
impl<I: Copy + Default> PredictionStrategy<I> for MyPrediction {
fn predict(&self, frame: Frame, last_confirmed: Option<I>, player_index: usize) -> I {
// Custom logic here
// CRITICAL: Must be deterministic across all peers!
last_confirmed.unwrap_or_default()
}
}
⚠️ Determinism Requirement: Custom prediction strategies MUST be deterministic. All peers must produce the exact same prediction given the same inputs, or desyncs will occur.
Note: The
PredictionStrategytrait requiresSend + Syncsupertrait bounds (pub trait PredictionStrategy<I>: Send + Sync). Your custom strategy type must be thread-safe.
Adjusting Input Delay at Runtime¶
SessionBuilder::with_input_delay fixes the input delay for the lifetime of a session. For matches that span variable network conditions, you can also adjust a local player's delay after the session has started via two P2PSession methods:
// In `P2PSession<C>`:
pub fn input_delay(&self, player_handle: PlayerHandle) -> Result<usize, FortressError>;
pub fn set_input_delay(
&mut self,
player_handle: PlayerHandle,
delay: usize,
) -> Result<(), FortressError>;
This enables hybrid delay+rollback netcode: keep the delay low when the link is healthy and raise it when round-trip time or jitter spikes, so misprediction frequency stays manageable without paying the full latency cost up front. See Network Quality Monitoring for the NetworkStats fields you would typically feed into the decision.
Constraints¶
Mid-session adjustments are deliberately limited so that the input stream remains strictly monotonic for every remote peer:
- Increases only. Lowering the delay after inputs have been added would require dropping inputs that may already have been transmitted to remote peers. Decreasing returns
InvalidRequestKind::InputDelayDecreaseUnsupported. - Single local player on this peer. The protocol bundles all local players' inputs into a single packet per frame. Synthesizing replicated bytes for the other local players' gap frames would require knowing inputs they have not yet produced, so increases are rejected with
InvalidRequestKind::InputDelayMidSessionMultiLocalUnsupportedwhen more than one local player is registered. UseSessionBuilder::with_input_delayinstead, before adding inputs, when you need to fix the delay for couch-co-op setups. - Pending-output capacity. A mid-session increase enqueues
delta = new_delay - current_delaygap-fill frames into every remote endpoint's pending-output buffer. Ifdeltais larger than the smallest remaining capacity across remote endpoints, the call returnsInvalidRequestKind::InputDelayMidSessionPendingOutputFullwithdeltaandcapacitypopulated for diagnostics. Apply the change in smaller increments or wait for outstanding inputs to be acknowledged.
A no-op call (delay == current_delay) returns Ok(()) without touching the queue. Setting a delay before any input has been added behaves like the builder method: it applies cleanly with no gap-fill, including decreases.
Gap-fill replication¶
When a mid-session increase is accepted, the input queue replicates the most recently added input across the new gap so subsequent sequential inputs continue to be accepted at the new boundary. The same replicated frames are pushed onto every remote endpoint's pending-output buffer and flushed immediately, so remote peers observe a continuous, strictly monotonic input sequence — they cannot tell the difference between a normal advance and a delay change.
In short: increasing the delay introduces no protocol-level discontinuity, at the cost of repeating the local player's last input for delta extra frames. Game logic that treats "held inputs" as meaningful (charging attacks, walking) should be aware that those frames will be observed by all peers.
Example: react to changing RTT¶
use fortress_rollback::{Config, FortressError, P2PSession, PlayerHandle};
const HIGH_PING_THRESHOLD_MS: u128 = 120;
const MAX_INPUT_DELAY: usize = 8;
/// Increase the local player's input delay when ping rises above
/// `HIGH_PING_THRESHOLD_MS`. Note: only an *initial-setup* decrease is
/// allowed; once inputs have been added, lowering returns
/// `InputDelayDecreaseUnsupported`. Most adaptive policies therefore treat
/// the delay as monotonically non-decreasing for the lifetime of a session.
fn adapt_input_delay<C: Config>(
session: &mut P2PSession<C>,
local_handle: PlayerHandle,
remote_handle: PlayerHandle,
) -> Result<(), FortressError> {
let stats = match session.network_stats(remote_handle) {
Ok(stats) => stats,
// `network_stats` returns `NotSynchronized` while the remote is still
// handshaking; treat it as "not ready yet" and retry next tick.
// Other errors (e.g., handle not registered, handle not a remote
// player) indicate a programming bug and are surfaced to the caller.
Err(FortressError::NotSynchronized) => return Ok(()),
Err(other) => return Err(other),
};
let current = session.input_delay(local_handle)?;
let target = if stats.ping >= HIGH_PING_THRESHOLD_MS {
current.saturating_add(1).min(MAX_INPUT_DELAY)
} else {
current
};
if target == current {
return Ok(());
}
match session.set_input_delay(local_handle, target) {
Ok(()) => Ok(()),
// Apply the change in smaller increments next tick.
Err(FortressError::InvalidRequestStructured {
kind:
fortress_rollback::InvalidRequestKind::InputDelayMidSessionPendingOutputFull {
..
},
}) => Ok(()),
// Surface anything else so the caller can decide how to react.
Err(other) => Err(other),
}
}
Decrease example¶
The decrease path is rejected explicitly so games can surface a meaningful error to the player:
use fortress_rollback::{Config, FortressError, InvalidRequestKind, P2PSession, PlayerHandle};
fn try_lower_delay<C: Config>(
session: &mut P2PSession<C>,
handle: PlayerHandle,
target: usize,
) -> Result<(), FortressError> {
match session.set_input_delay(handle, target) {
Ok(()) => Ok(()),
Err(FortressError::InvalidRequestStructured {
kind: InvalidRequestKind::InputDelayDecreaseUnsupported { current, requested },
}) => {
eprintln!(
"Cannot lower input delay from {current} to {requested} mid-session; \
wait for the next match to take effect."
);
Ok(())
},
Err(other) => Err(other),
}
}
FortressEvent::InputDelayRecommendation¶
The library reserves a FortressEvent::InputDelayRecommendation { player_handle, current_delay, suggested_delay } variant for application-level heuristics or future automatic emitters. No built-in emitter currently produces this event. Application code may construct and dispatch its own recommendations through the standard event channel and react to them via set_input_delay, or simply call set_input_delay directly from its own scheduling logic. Exhaustive matches on FortressEvent must still handle the variant — see the Migration Guide.
Feature Flags¶
Fortress Rollback provides several Cargo feature flags to customize behavior for different use cases. This section documents all available features, their purposes, and valid combinations.
Feature Flag Reference¶
| Feature | Description | Use Case | Dependencies |
|---|---|---|---|
sync-send |
Adds Send + Sync bounds to core traits |
Multi-threaded game engines | None |
tokio |
Enables TokioUdpSocket for async Tokio applications |
Async game servers | tokio crate |
json |
Enables JSON serialization for telemetry types | Structured logging/monitoring | serde_json crate |
paranoid |
Enables runtime invariant checking in release builds | Debugging production issues | None |
loom |
Enables Loom-compatible synchronization primitives | Concurrency testing | loom crate |
z3-verification |
Enables Z3 formal verification tests | Development/CI verification | z3 crate (system) |
z3-verification-bundled |
Z3 with bundled build (builds from source) | CI environments without system Z3 | z3 crate |
graphical-examples |
Enables the ex_game graphical examples | Running visual demos | macroquad crate |
Note: WASM support is automatic — no feature flag needed. See Web / WASM Integration below.
Feature Details¶
sync-send¶
When enabled, the Config and NonBlockingSocket traits require their associated types to be Send + Sync. This is necessary for multi-threaded game engines like Bevy that may access session data from multiple threads.
Without sync-send:
pub trait Config: 'static {
type Input: Copy + Clone + PartialEq + Eq + Default + Serialize + DeserializeOwned;
type State;
type Address: Clone + PartialEq + Eq + PartialOrd + Ord + Hash + Debug;
}
With sync-send:
pub trait Config: 'static + Send + Sync {
type Input: Copy + Clone + PartialEq + Eq + Default + Serialize + DeserializeOwned + Send + Sync;
type State: Clone + Send + Sync;
type Address: Clone + PartialEq + Eq + PartialOrd + Ord + Hash + Send + Sync + Debug;
}
tokio¶
Enables TokioUdpSocket, an adapter that wraps a Tokio async UDP socket and implements NonBlockingSocket for use with Fortress Rollback sessions in async Tokio applications.
Example usage:
use fortress_rollback::tokio_socket::TokioUdpSocket;
use fortress_rollback::{SessionBuilder, PlayerType, PlayerHandle};
use std::net::SocketAddr;
// MyConfig is your game's Config implementation (see "Defining Your Config")
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create and bind a Tokio UDP socket adapter
let socket = TokioUdpSocket::bind_to_port(7000).await?;
let remote_addr: SocketAddr = "127.0.0.1:7001".parse()?;
// Use with SessionBuilder
let session = SessionBuilder::<MyConfig>::new()
.with_num_players(2)?
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(remote_addr), PlayerHandle::new(1))?
.start_p2p_session(socket)?;
Ok(())
}
Note: When used with the sync-send feature, TokioUdpSocket automatically implements Send + Sync.
json¶
Enables JSON serialization methods (to_json() and to_json_pretty()) on telemetry types like SpecViolation and InvariantViolation. This is useful for structured logging, monitoring systems, or exporting violation data.
Example usage:
use fortress_rollback::telemetry::{SpecViolation, ViolationSeverity, ViolationKind};
let violation = SpecViolation::new(
ViolationSeverity::Warning,
ViolationKind::FrameSync,
"frame mismatch detected",
"sync.rs:42",
);
// With the `json` feature enabled:
if let Some(json) = violation.to_json() {
println!("Violation: {}", json);
// Output: {"severity":"warning","kind":"frame_sync","message":"frame mismatch detected",...}
}
Note: Without the json feature, the telemetry types still implement serde::Serialize and can be serialized with any serde-compatible serializer (like bincode). The json feature specifically enables the convenience to_json() methods and adds the serde_json dependency.
paranoid¶
Enables runtime invariant checking in release builds. Normally, invariant checks (using the internal invariant_assert! macro) only run in debug builds. With paranoid enabled, these checks also run in release mode, which is useful for debugging production issues.
Use cases:
- Debugging desync issues in production
- Verifying invariants under real-world conditions
- Running integration tests with production-like builds
Performance note: Enabling paranoid may impact performance due to additional runtime checks. Use it temporarily for debugging rather than in shipped builds.
loom¶
Enables Loom-compatible synchronization primitives for deterministic concurrency testing. When enabled, internal synchronization primitives switch from parking_lot to Loom's equivalents.
Note: This is a compile-time flag (cfg(loom)) and should not be enabled in Cargo.toml. Instead, it's used via RUSTFLAGS:
See loom-tests/README.md for details on running concurrency tests.
Development/Contributor Feature Flags¶
These feature flags are primarily for library development and CI, not typical user applications.
z3-verification¶
Enables Z3 formal verification tests. Requires the Z3 SMT solver library to be installed on your system.
System installation (recommended):
# Debian/Ubuntu
sudo apt install libz3-dev
# macOS
brew install z3
# Then run verification tests
cargo test --features z3-verification
What it tests:
- Frame arithmetic invariants
- Buffer bounds safety
- Desync detection correctness
- Input queue safety properties
z3-verification-bundled¶
Like z3-verification, but builds Z3 from source. This is useful for CI environments where system Z3 is not available.
Warning: Building Z3 from source takes 30+ minutes. Use z3-verification with system Z3 when possible.
graphical-examples¶
Enables the interactive game examples that use macroquad for graphics and audio.
System dependencies (Linux):
Running examples:
cargo run --example ex_game_p2p --features graphical-examples -- --local-port 7000 --players localhost 127.0.0.1:7001
Feature Flag Combinations¶
Most features are independent and can be combined freely. Here's a matrix showing valid combinations:
| Combination | Valid | Notes |
|---|---|---|
sync-send + paranoid |
✅ | Debug multi-threaded issues |
sync-send + tokio |
✅ | Common for async servers |
paranoid + z3-verification |
✅ | Maximum verification |
z3-verification + z3-verification-bundled |
⚠️ | Redundant (bundled implies base) |
loom + any other |
⚠️ | Loom tests should run in isolation |
graphical-examples + any |
✅ | Examples are independent |
Recommended combinations:
# Standard multi-threaded game
[dependencies]
fortress-rollback = { version = "0.8", features = ["sync-send"] }
# Async server with Tokio
[dependencies]
fortress-rollback = { version = "0.8", features = ["sync-send", "tokio"] }
# Debugging production issues
[dependencies]
fortress-rollback = { version = "0.8", features = ["sync-send", "paranoid"] }
# Development with examples
[dependencies]
fortress-rollback = { version = "0.8", features = ["sync-send", "graphical-examples"] }
Web / WASM Integration¶
Fortress Rollback works in the browser with no feature flags required. The library automatically detects target_arch = "wasm32" at compile time and uses browser-compatible APIs.
What Works Automatically¶
| Component | Native | WASM |
|---|---|---|
Time (Instant) |
std::time |
web_time crate |
| Epoch time | SystemTime |
js_sys::Date |
| Core rollback logic | ✅ | ✅ |
UdpNonBlockingSocket |
✅ | ❌ (no UDP in browsers) |
Networking in the Browser¶
Browsers don't support raw UDP sockets. For browser games, you need WebRTC or WebSockets. The recommended solution is Matchbox:
[dependencies]
fortress-rollback = { version = "0.8", features = ["sync-send"] }
matchbox_socket = { version = "0.13", features = ["ggrs"] }
Matchbox provides:
- WebRTC peer-to-peer connections — Direct data channels between browsers
- Signaling server — Only needed during connection establishment
- Cross-platform — Same API works on native and WASM
- GGRS compatibility — The
ggrsfeature implementsNonBlockingSocket
Basic Matchbox Integration¶
use fortress_rollback::{Config, PlayerHandle, PlayerType, SessionBuilder};
use matchbox_socket::WebRtcSocket;
// Create matchbox socket (connects to signaling server)
let (socket, message_loop) = WebRtcSocket::new_ggrs("wss://matchbox.example.com/my_game");
// Spawn the message loop (required for WASM)
#[cfg(target_arch = "wasm32")]
wasm_bindgen_futures::spawn_local(message_loop);
// Wait for peers to connect...
// (In practice, poll socket.connected_peers() until you have enough)
// Create session with matchbox socket
let session = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(peer_id), PlayerHandle::new(1))?
.start_p2p_session(socket)?;
Custom Transport (WebSockets, etc.)¶
For other transports, implement NonBlockingSocket:
use fortress_rollback::{Message, NonBlockingSocket};
struct MyWebSocketTransport {
// Your WebSocket implementation
}
impl NonBlockingSocket<MyPeerId> for MyWebSocketTransport {
fn send_to(&mut self, msg: &Message, addr: &MyPeerId) {
// Serialize msg and send via WebSocket
let Ok(bytes) = bincode::serialize(msg) else { return };
self.send_to_peer(addr, &bytes);
}
fn receive_all_messages(&mut self) -> Vec<(MyPeerId, Message)> {
// Return all messages received since last call
self.drain_received_messages()
.filter_map(|(peer, bytes)| {
bincode::deserialize(&bytes).ok().map(|msg| (peer, msg))
})
.collect()
}
}
See the custom socket example for a complete implementation guide.
Building for WASM¶
# Install wasm-pack
cargo install wasm-pack
# Build for web
wasm-pack build --target web
# Or use trunk for development
cargo install trunk
trunk serve
Binary Size Optimization¶
For smaller WASM binaries, add to your project's Cargo.toml:
This trades some runtime performance for smaller binaries. Test both "s" and "z" to find the best tradeoff for your game.
Platform-Specific Features¶
Fortress Rollback automatically adapts to different platforms:
| Platform | Time Source | Socket Support | Notes |
|---|---|---|---|
| Native (Linux/macOS/Windows) | std::time::SystemTime |
UDP via std::net |
Full support |
| WebAssembly | js_sys::Date |
Custom via NonBlockingSocket |
Use Matchbox for WebRTC |
| No-std | ❌ Not supported | ❌ | Requires allocator |
WASM considerations:
- The library automatically uses JavaScript's
Date.getTime()for time functions - Implement
NonBlockingSocketusing WebRTC (see Matchbox) - Determinism is maintained across platforms with the same inputs
Spectator Sessions¶
Spectators observe gameplay without contributing inputs:
Host Side (P2P Session)¶
let spectator_addr = "192.168.1.200:8000".parse()?;
let session = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(player2_addr), PlayerHandle::new(1))?
// Add spectator with handle >= num_players
.add_player(PlayerType::Spectator(spectator_addr), PlayerHandle::new(2))?
.start_p2p_session(socket)?;
Spectator Side¶
use fortress_rollback::{FortressError, SessionBuilder, SessionState, UdpNonBlockingSocket};
let host_addr = "192.168.1.100:7000".parse()?;
let socket = UdpNonBlockingSocket::bind_to_port(8000)?;
// Note: start_spectator_session returns Option<SpectatorSession>
let mut session = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
.with_max_frames_behind(10)? // When to start catching up
.with_catchup_speed(2)? // How fast to catch up
.start_spectator_session(host_addr, socket)
.ok_or(FortressError::InvalidRequest {
info: "spectator session initialization failed".into(),
})?;
// Spectator loop
loop {
session.poll_remote_clients();
for event in session.events() {
// Handle sync events
}
if session.current_state() == SessionState::Running {
for request in session.advance_frame()? {
// Handle requests (no save/load, only AdvanceFrame)
}
}
}
Testing with SyncTest¶
SyncTestSession helps verify determinism:
let mut session = SessionBuilder::<GameConfig>::new()
.with_num_players(2)?
.with_check_distance(4) // Compare last 4 frames
.with_input_delay(2)?
.start_synctest_session()?;
// Run game loop - players are created automatically from with_num_players()
for frame in 0..1000 {
// Provide input for all players
for handle in session.local_player_handles() {
session.add_local_input(handle, random_input())?;
}
let requests = session.advance_frame()?;
handle_requests(requests, &mut game_state);
}
If checksums mismatch, you have a determinism bug!
Using the Session Trait¶
Fortress Rollback provides a unified Session<T> trait that all session types implement. This lets you write generic code that works identically with P2PSession, SpectatorSession, and SyncTestSession — no code duplication required.
The trait is available in the prelude:
Why Use the Session Trait?¶
- Write once, run anywhere: A single game loop function works for P2P, spectator, and sync testing
- Easier testing: Swap a
P2PSessionfor aSyncTestSessionwithout changing game logic - Cleaner architecture: Decouple your game loop from a specific session type
Method Overview¶
The Session trait has 2 required methods and 4 provided methods with defaults:
| Method | P2PSession |
SpectatorSession |
SyncTestSession |
|---|---|---|---|
advance_frame() |
✅ Override | ✅ Override | ✅ Override |
local_player_handle_required() |
✅ Override | ✅ Override (error) | ✅ Override |
add_local_input() |
✅ Override | ✅ Override (error) | ✅ Override |
events() |
✅ Override | ✅ Override | ✅ Override |
current_state() |
✅ Override | ✅ Override | ❌ Default (Running) |
poll_remote_clients() |
✅ Override | ✅ Override | ❌ Default (no-op) |
Methods marked "Default" return a sensible no-op or constant. For example, SyncTestSession has no network, so poll_remote_clients() is a no-op.
Note that network_stats() is deliberately not on the trait — it only makes sense for networked sessions and takes a PlayerHandle argument that varies by session type.
Writing Generic Functions¶
Use &mut impl Session<T> to accept any session type:
use fortress_rollback::prelude::*;
fn run_frame<T: Config>(
session: &mut impl Session<T>,
input: T::Input,
) -> FortressResult<RequestVec<T>> {
let player = session.local_player_handle_required()?;
session.add_local_input(player, input)?;
let requests = session.advance_frame()?;
Ok(requests)
}
This function works with any session:
# use fortress_rollback::prelude::*;
# fn run_frame<T: Config>(
# session: &mut impl Session<T>,
# input: T::Input,
# ) -> FortressResult<RequestVec<T>> {
# let player = session.local_player_handle_required()?;
# session.add_local_input(player, input)?;
# let requests = session.advance_frame()?;
# Ok(requests)
# }
// Works with P2PSession
// run_frame(&mut p2p_session, my_input)?;
// Works with SyncTestSession
// run_frame(&mut sync_test_session, my_input)?;
Practical Example: Generic Game Loop¶
Here is a more complete example that handles requests generically:
use fortress_rollback::prelude::*;
fn game_loop_step<T: Config>(
session: &mut impl Session<T>,
input: T::Input,
game_state: &mut T::State,
) -> FortressResult<()>
where
T::State: Clone,
{
// Poll for network data (no-op for SyncTestSession)
session.poll_remote_clients();
// Add input and advance
let player = session.local_player_handle_required()?;
session.add_local_input(player, input)?;
for request in session.advance_frame()? {
match request {
FortressRequest::SaveGameState { cell, frame } => {
cell.save(frame, Some(game_state.clone()), None);
}
FortressRequest::LoadGameState { cell, .. } => {
if let Some(loaded) = cell.load() {
*game_state = loaded;
}
}
FortressRequest::AdvanceFrame { inputs } => {
// Apply inputs to your game state
let _ = &inputs; // placeholder — call your update function
}
}
}
// Drain events (works for all session types)
for event in session.events() {
let _ = event; // handle events as needed
}
Ok(())
}
Gradual Adoption¶
The Session trait is additive — adopting it does not require changing existing code. You can:
- Continue using concrete session types directly (nothing changes)
- Start writing new helper functions as generic over
impl Session<T> - Gradually move your game loop to trait-based code as needed
For the trait's design rationale, see the Architecture Guide. If migrating from concrete session types, the Migration Guide covers adoption.
Common Patterns¶
Handling Disconnected Players¶
FortressRequest::AdvanceFrame { inputs } => {
for (i, (input, status)) in inputs.iter().enumerate() {
if *status == InputStatus::Disconnected {
// Option 1: Freeze the player
continue;
// Option 2: Simple AI
// let ai_input = compute_ai_input(&game_state, i);
// apply_input(&mut game_state, i, ai_input);
// Option 3: Last known input (already done by Fortress Rollback)
// apply_input(&mut game_state, i, *input);
} else {
apply_input(&mut game_state, i, *input);
}
}
}
By default, a P2P session halts as soon as any peer drops: confirmed_frame() stops advancing and advance_frame() will not progress further. Halting is the legacy GGRS-compatible behavior and is appropriate for 1v1 competitive matches where a disconnect should end the round. For 3+ player games, casual matches, or any session that should keep advancing for the surviving peers, opt in to graceful drop via DisconnectBehavior::ContinueWithout or call P2PSession::remove_player explicitly. See the next section for the full graceful-drop API and a worked example.
Multiple Local Players (Couch Co-op)¶
let session = SessionBuilder::<GameConfig>::new()
.with_num_players(4)?
// Two local players, two remote
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Local, PlayerHandle::new(1))?
.add_player(PlayerType::Remote(addr1), PlayerHandle::new(2))?
.add_player(PlayerType::Remote(addr2), PlayerHandle::new(3))?
.start_p2p_session(socket)?;
// In game loop, add input for BOTH local players
for handle in session.local_player_handles() {
let input = get_input_for_player(handle);
session.add_local_input(handle, input)?;
}
Frame Pacing¶
Slow down when ahead to reduce rollbacks:
let base_fps = 60.0;
let frame_time = 1.0 / base_fps;
let adjusted_time = if session.frames_ahead() > 2 {
frame_time * 1.1 // Slow down 10%
} else if session.frames_ahead() < -2 {
frame_time * 0.9 // Speed up 10% (be careful!)
} else {
frame_time
};
Disconnect Behavior and Graceful Peer Drop¶
A P2PSession exposes two complementary mechanisms for reacting to a remote peer that goes away:
DisconnectBehavior— a session-wide policy that controls what happens when the automatic disconnect-timeout fires for a peer that has gone silent.P2PSession::remove_player— an explicit method to drop a peer immediately (e.g., kick, surrender, "leave match").
Both can produce graceful drops where the session continues advancing for the surviving peers; the policy controls only the automatic-timeout path, while remove_player always opts into the graceful flow. Together they give applications a single, consistent way to handle players leaving mid-match.
Choosing a DisconnectBehavior¶
pub enum DisconnectBehavior {
/// Halt the session: no further frames advance once any peer drops. Default.
Halt,
/// Continue the session with the remaining peers, freezing the dropped
/// peer's input queue at their last confirmed input.
ContinueWithout,
}
| Variant | Use when |
|---|---|
Halt (default) |
1v1 competitive matches where a disconnect should end the round; legacy GGRS-compatible behavior; any flow that wants the application layer to detect the stop and tear down. |
ContinueWithout |
3+ player games (free-for-all, team modes, casual lobbies); games where a disconnect should hand the slot to AI; spectator-friendly games where the show must go on. |
Halt preserves the legacy GGRS-style "stop on any peer drop" behavior: after a timeout fires, confirmed_frame() stops advancing and the next advance_frame() will be unable to progress. This is the default for backwards compatibility.
ContinueWithout makes the automatic disconnect-timeout follow the same graceful-drop sequence as the explicit remove_player call: the dropped peer's input queue is frozen (it repeats their last confirmed input forever, with InputStatus::Disconnected), a FortressEvent::PeerDropped event is queued, and remaining peers keep advancing using the frozen input.
Note:
DisconnectBehaviorgoverns only the automatic disconnect-timeout path. The legacyP2PSession::disconnect_playerretains its non-graceful semantics regardless of this setting; useremove_playerfor an explicit graceful drop.
Configuring Graceful Drop¶
Opt in via SessionBuilder::with_disconnect_behavior (contract):
use fortress_rollback::{DisconnectBehavior, PlayerHandle, PlayerType, SessionBuilder};
use std::net::SocketAddr;
use std::time::Duration;
# struct GameConfig;
# impl fortress_rollback::Config for GameConfig {
# type Input = u8;
# type State = ();
# type Address = SocketAddr;
# }
# fn build(socket: impl fortress_rollback::NonBlockingSocket<SocketAddr>)
# -> Result<(), Box<dyn std::error::Error>> {
let addr1: SocketAddr = "127.0.0.1:7001".parse()?;
let addr2: SocketAddr = "127.0.0.1:7002".parse()?;
let session = SessionBuilder::<GameConfig>::new()
.with_num_players(3)?
.add_player(PlayerType::Local, PlayerHandle::new(0))?
.add_player(PlayerType::Remote(addr1), PlayerHandle::new(1))?
.add_player(PlayerType::Remote(addr2), PlayerHandle::new(2))?
.with_disconnect_timeout(Duration::from_millis(2_000))
.with_disconnect_behavior(DisconnectBehavior::ContinueWithout)
.start_p2p_session(socket)?;
assert_eq!(session.disconnect_behavior(), DisconnectBehavior::ContinueWithout);
# Ok(()) }
P2PSession::disconnect_behavior() returns the configured policy at any time, which is useful for diagnostics and for game-layer code that wants to display the active rule to players.
Reacting to PeerDropped Events¶
When a graceful drop occurs (either from an automatic timeout under ContinueWithout or from a call to remove_player), the session emits the following events for the dropped endpoint in the same events() batch, in this order:
- One
FortressEvent::PeerDropped { handle, addr }per non-spectator player handle owned by the dropped endpoint. For the common single-handle-per-peer case this is exactly one event; for multi-handle endpoints (multiple players sharing a single remote address) it is one event per handle. - A single address-level
FortressEvent::Disconnected { addr }after allPeerDroppedevents for that endpoint. Existing applications continue to receive this unchanged.
PeerDropped is per-handle; Disconnected is per-address. After the events are emitted, every non-spectator handle owned by the dropped endpoint has its input frozen at its last confirmed value; subsequent AdvanceFrame requests will deliver that input with InputStatus::Disconnected for every frame past each handle's last confirmed frame.
use fortress_rollback::{Config, FortressEvent, P2PSession};
fn handle_peer_drops<C: Config>(session: &mut P2PSession<C>)
where
C::Address: std::fmt::Display,
{
for event in session.events() {
match event {
FortressEvent::PeerDropped { handle, addr } => {
// Graceful drop: the session is still advancing for the
// remaining peers; this peer's input is frozen at their
// last confirmed value.
println!("Peer {handle} ({addr}) dropped — switching to AI control");
// game.mark_ai_controlled(handle);
},
FortressEvent::Disconnected { addr } => {
// Legacy event: arrives in the same batch as PeerDropped on
// graceful drops, and on its own under DisconnectBehavior::Halt.
println!("Disconnected from {addr}");
},
_ => {},
}
}
}
A complete game loop using ContinueWithout looks like this:
use fortress_rollback::{
Config, DisconnectBehavior, FortressError, FortressEvent, FortressRequest, P2PSession,
PlayerHandle, SessionState,
};
fn run<C: Config>(
mut session: P2PSession<C>,
local_handle: PlayerHandle,
mut next_input: impl FnMut() -> C::Input,
) -> Result<(), FortressError>
where
C::Address: std::fmt::Display,
{
assert_eq!(session.disconnect_behavior(), DisconnectBehavior::ContinueWithout);
loop {
session.poll_remote_clients();
for event in session.events() {
if let FortressEvent::PeerDropped { handle, addr } = event {
eprintln!("Peer {handle} at {addr} left — game continues");
// Application-specific cleanup (UI, scoring, etc.) goes here.
}
}
if session.current_state() != SessionState::Running {
continue;
}
session.add_local_input(local_handle, next_input())?;
for request in session.advance_frame()? {
match request {
FortressRequest::SaveGameState { cell, frame } => {
let _ = (cell, frame); // save your state
},
FortressRequest::LoadGameState { cell, frame } => {
let _ = (cell, frame); // restore your state
},
FortressRequest::AdvanceFrame { inputs } => {
let _ = inputs; // apply inputs to the simulation
},
}
}
// ... pacing, exit conditions, etc.
# break Ok(());
}
}
Explicit Peer Removal with remove_player¶
// In `P2PSession<C>`:
pub fn remove_player(&mut self, player_handle: PlayerHandle) -> Result<(), FortressError>;
remove_player performs a graceful drop immediately, regardless of the configured DisconnectBehavior. Use it whenever the drop is initiated by application logic rather than by network silence:
- The player chose to leave the match (concession, surrender, "leave game" UI button).
- A moderator or host kicked the player.
- The application detects, out-of-band, that the peer is unreachable and wants to fail fast rather than wait out the disconnect timeout.
On invocation, remove_player performs the same sequence as the auto-timeout path under ContinueWithout:
- Marks the player disconnected on the local connection-status table.
- Freezes the player's input queue (it repeats the last confirmed input forever for remaining peers' simulation).
- Disconnects the underlying network endpoint.
- Emits
FortressEvent::PeerDropped { handle, addr }, followed byFortressEvent::Disconnected { addr }in the sameevents()batch.
The combined effect is that surviving peers see exactly the same protocol-level state as if the dropped peer had timed out under ContinueWithout, but the drop happens on the next session call instead of after a timeout.
use fortress_rollback::{
Config, FortressError, InvalidRequestKind, P2PSession, PlayerHandle,
};
fn handle_concede<C: Config>(
session: &mut P2PSession<C>,
conceding_remote: PlayerHandle,
) -> Result<(), FortressError> {
match session.remove_player(conceding_remote) {
Ok(()) => {
// The next events() call will yield PeerDropped + Disconnected
// for `conceding_remote`. Surviving peers continue advancing.
Ok(())
},
Err(FortressError::InvalidRequestStructured {
kind: InvalidRequestKind::PlayerAlreadyRemoved { handle },
}) => {
// remove_player was called twice for the same handle (or the
// peer was already removed by an auto-timeout under
// ContinueWithout). Treat as a no-op.
eprintln!("Peer {handle} was already removed; ignoring");
Ok(())
},
Err(other) => Err(other),
}
}
Errors¶
| Error | When it triggers |
|---|---|
InvalidRequestKind::DisconnectInvalidHandle { handle } |
handle is not registered, or refers to a spectator (use spectator-specific cleanup instead). |
InvalidRequestKind::DisconnectLocalPlayer { handle } |
handle refers to a local player. Local players cannot be removed; tear down the session instead. |
InvalidRequestKind::PlayerAlreadyRemoved { handle } |
handle is already marked disconnected — either by a previous remove_player call, by auto-removal under ContinueWithout, or by a previous explicit disconnect_player call. |
Spectator endpoints at the same address¶
remove_player accepts only Remote handles (it rejects Spectator handles with DisconnectInvalidHandle); it operates on the Remote endpoint at the targeted address. A Spectator endpoint registered at the same T::Address is an independent endpoint and is not affected by remove_player — it remains running and continues receiving forwarded inputs until it disconnects on its own.
disconnect_player accepts both Remote and Spectator handles. When the targeted handle is Remote, disconnect_player operates on the Remote endpoint only; a co-located Spectator at the same address is left running. When the targeted handle is Spectator, only that specific spectator endpoint is disconnected; any Remote endpoint at the same address is left running.
Co-locating a Remote and a Spectator at the same address is unusual; this note documents the behavior for that edge case.
Choosing Between disconnect_player and remove_player¶
The session also exposes a legacy disconnect_player(handle) method preserved from GGRS. It is not the same as remove_player:
| Aspect | disconnect_player (legacy) |
remove_player (graceful) |
|---|---|---|
| Marks player disconnected | Yes | Yes |
| Disconnects network endpoint | Yes | Yes |
| Freezes input queue | No — remaining peers no longer produce confirmed inputs from the dropped peer's endpoint, so advance_frame cannot make progress past the dropped peer's last confirmed frame under default Halt |
Yes — last confirmed input repeats forever; surviving peers keep advancing |
Emits FortressEvent::PeerDropped |
No | Yes, immediately followed by Disconnected |
Honors DisconnectBehavior |
No — disconnect_player always performs the legacy non-graceful drop regardless of DisconnectBehavior. Under default Halt the session halts (remaining peers can no longer produce inputs from the dropped peer's endpoint, so advance_frame cannot progress). Under ContinueWithout it does not auto-promote to the graceful flow; the queue is not frozen, no PeerDropped is emitted, and advance_frame halts just as it does under Halt. To get the graceful sequence with explicit removal, call remove_player instead. |
No — always performs the graceful sequence, regardless of policy |
| Use case | Back-compat with code written against GGRS's disconnect_player |
Application-driven graceful drop (kick, surrender, leave match) |
If you are writing new code, prefer remove_player. Reach for disconnect_player only when porting GGRS code that depends on its specific halt-on-drop semantics under DisconnectBehavior::Halt.
Common Pitfalls¶
This section covers subtle API misuses that can lead to hard-to-debug issues. These patterns may appear to work in many cases but will fail under specific conditions.
Session Termination: The last_confirmed_frame() Anti-Pattern¶
The Problem:
A common mistake is using confirmed_frame() to determine when a session is "complete":
// ⚠️ WRONG: This is a pit of failure!
if session.confirmed_frame() >= target_frames {
break; // Stop simulation
}
This seems logical but is fundamentally incorrect because:
last_confirmed_frame()means "no more rollbacks will affect this frame" - It does NOT mean "both peers have simulated to the same frame"- Peers run asynchronously - Peer 1 may confirm frame 100 while Peer 2 is still at frame 80
- There's no synchronization point - When Peer 1 stops, Peer 2 continues, accumulating more state changes
- Final values diverge - Not due to non-determinism, but because peers simulate different total frames
The Symptom:
Both peers show different final values even though game logic is perfectly deterministic. The frame counts differ (e.g., 179 vs 184), and checksums differ because they represent different frames.
The Solution:
Use the SyncHealth API to verify both peers agree before termination:
use fortress_rollback::SyncHealth;
let mut my_done = false;
let mut peer_done = false;
loop {
// Normal frame advance
session.poll_remote_clients();
if session.current_state() == SessionState::Running {
session.add_local_input(local_handle, input)?;
for request in session.advance_frame()? {
match request {
// Handle requests normally...
# FortressRequest::SaveGameState { cell, frame } => {
# cell.save(frame, Some(game_state.clone()), None);
# }
# FortressRequest::LoadGameState { cell, .. } => {
# if let Some(state) = cell.load() { game_state = state; }
# }
# FortressRequest::AdvanceFrame { inputs } => {
# game_state.update(&inputs);
# }
}
}
}
// Check if WE think we're done
if !my_done && session.confirmed_frame() >= target_frames {
my_done = true;
send_done_message_to_peer(); // Application-level protocol
}
// Check if peer says they're done (via your application protocol)
if received_done_from_peer() {
peer_done = true;
}
// Only exit when BOTH are done AND sync health is verified
if my_done && peer_done {
match session.sync_health(peer_handle) {
Some(SyncHealth::InSync) => break, // Safe to exit
Some(SyncHealth::DesyncDetected { frame, .. }) => {
// Desync is a critical error — your application decides how to handle it.
// Options: return error, show UI, attempt recovery, or terminate.
eprintln!("Desync detected at frame {} — investigation required", frame);
return Err(format!("Desync detected at frame {}", frame).into());
}
_ => continue, // Still waiting for checksum verification
}
}
}
Key Points:
- Use
sync_health()to verify checksums match before termination - Implement application-level "done" messaging between peers
- Don't trust
confirmed_frame()alone for termination decisions - Consider using
last_verified_frame()to ensure checksum verification has occurred
Understanding Desync Detection Defaults¶
Desync detection is enabled by default with DesyncDetection::On { interval: 60 } (once per second at 60fps). This means:
- Checksums are computed and exchanged automatically
sync_health()will reportSyncHealth::InSyncorSyncHealth::DesyncDetected- Desync bugs are detected early, before they cause visible gameplay issues
If you need to disable detection (e.g., for performance benchmarking):
use fortress_rollback::DesyncDetection;
let session = SessionBuilder::<GameConfig>::new()
.with_desync_detection_mode(DesyncDetection::Off) // Explicit opt-out
// ... other configuration
.start_p2p_session(socket)?;
For tighter detection (competitive games, anti-cheat), reduce the interval:
use fortress_rollback::DesyncDetection;
let session = SessionBuilder::<GameConfig>::new()
.with_desync_detection_mode(DesyncDetection::On { interval: 10 }) // 6x per second at 60fps
// ... other configuration
.start_p2p_session(socket)?;
The interval parameter determines how many frames between checksum exchanges. Lower values detect desyncs faster but increase network overhead.
NetworkStats Checksum Fields for Desync Detection¶
NetworkStats now includes fields for monitoring desync status:
let stats = session.network_stats(peer_handle)?;
// Check the most recent checksum comparison
if let Some(last_frame) = stats.last_compared_frame {
println!("Last compared frame: {}", last_frame);
println!("Local checksum: {:?}", stats.local_checksum);
println!("Remote checksum: {:?}", stats.remote_checksum);
// Quick check using the convenience field
match stats.checksums_match {
Some(true) => println!("✓ Synchronized"),
Some(false) => println!("✗ DESYNC DETECTED"),
None => println!("? No comparison yet"),
}
}
| Field | Type | Description |
|---|---|---|
last_compared_frame |
Option<Frame> |
Most recent frame where checksums were compared |
local_checksum |
Option<u128> |
Local checksum at that frame |
remote_checksum |
Option<u128> |
Remote checksum at that frame |
checksums_match |
Option<bool> |
true if in sync, false if desync, None if no comparison |
Desync Detection and SyncHealth API¶
Fortress Rollback provides comprehensive APIs for detecting and monitoring synchronization state between peers. This section covers the SyncHealth API, which is essential for proper session management and termination.
Understanding Desync Detection¶
Desync (desynchronization) occurs when peers' game states diverge, typically due to non-deterministic code. Without detection, desyncs cause subtle bugs that are extremely difficult to debug—players see different game states while believing everything is working correctly.
Key Points:
- Desync detection is enabled by default with
DesyncDetection::On { interval: 60 }(once per second at 60fps) - Detection works by periodically comparing game state checksums between peers
- Early detection prevents subtle multiplayer issues from reaching production
The SyncHealth Enum¶
The SyncHealth enum represents the synchronization state with a specific peer:
use fortress_rollback::SyncHealth;
pub enum SyncHealth {
/// Checksums have been compared and match - peers are synchronized.
InSync,
/// Waiting for checksum data from the peer - status unknown.
Pending,
/// Checksums were compared and differ - desync detected!
DesyncDetected {
frame: Frame,
local_checksum: u128,
remote_checksum: u128,
},
}
SyncHealth API Methods¶
sync_health(player_handle) — Check Specific Peer Status¶
Returns the synchronization status for a specific remote peer:
match session.sync_health(peer_handle) {
Some(SyncHealth::InSync) => {
// Checksums match - peers are synchronized
println!("✓ Peer {} is in sync", peer_handle);
}
Some(SyncHealth::Pending) => {
// Waiting for checksum exchange - status unknown
println!("⏳ Waiting for checksum from peer {}", peer_handle);
}
Some(SyncHealth::DesyncDetected { frame, local_checksum, remote_checksum }) => {
// CRITICAL: Checksums differ - game states diverged!
eprintln!("✗ DESYNC at frame {} with peer {}", frame, peer_handle);
eprintln!(" Local: {:#034x}", local_checksum);
eprintln!(" Remote: {:#034x}", remote_checksum);
// You should probably panic or disconnect here
}
None => {
// Not a remote player (local player or invalid handle)
}
}
is_synchronized() — Quick All-Peers Check¶
Returns true only if ALL remote peers report InSync:
if session.is_synchronized() {
println!("All peers verified in sync");
} else {
// Either waiting for checksums (Pending) or desync detected
}
last_verified_frame() — Highest Verified Frame¶
Returns the highest frame where checksums were verified to match:
if let Some(frame) = session.last_verified_frame() {
println!("Verified sync up to frame {}", frame);
} else {
// No checksum verification has occurred yet
}
all_sync_health() — Detailed Status for All Peers¶
Returns a vector of (PlayerHandle, SyncHealth) for all remote peers:
for (handle, health) in session.all_sync_health() {
match health {
SyncHealth::InSync => println!("Peer {}: ✓ In Sync", handle),
SyncHealth::Pending => println!("Peer {}: ⏳ Pending", handle),
SyncHealth::DesyncDetected { frame, .. } => {
println!("Peer {}: ✗ DESYNC at frame {}", handle, frame);
}
}
}
Common Usage Patterns¶
Safe Session Termination¶
The most critical use of SyncHealth is ensuring proper session termination:
// Application-level done tracking
let mut my_done = false;
let mut peer_done = false;
loop {
session.poll_remote_clients();
if session.current_state() == SessionState::Running {
// Normal frame processing...
}
// Mark ourselves done when we reach target
if !my_done && session.confirmed_frame() >= target_frames {
my_done = true;
// Send "I'm done" to peer via your application protocol
}
// Update when peer says they're done
if received_done_from_peer() {
peer_done = true;
}
// Only exit when BOTH done AND verified in sync
if my_done && peer_done {
match session.sync_health(peer_handle) {
Some(SyncHealth::InSync) => {
println!("Session complete and verified!");
break;
}
Some(SyncHealth::DesyncDetected { frame, .. }) => {
// Desync handling is application-specific — you decide the response.
eprintln!("Cannot terminate: desync at frame {} — investigation required", frame);
return Err(format!("Desync at frame {}", frame).into());
}
_ => continue, // Keep polling for verification
}
}
}
Monitoring During Gameplay¶
Use all_sync_health() for debug overlays or logging:
// Every N frames, log sync status
if frame % 300 == 0 { // Every 5 seconds at 60fps
for (handle, health) in session.all_sync_health() {
log::debug!("Peer {} sync status: {:?}", handle, health);
}
}
Desync Response Strategies¶
Different games may want different responses to desyncs:
match session.sync_health(peer_handle) {
Some(SyncHealth::DesyncDetected { frame, .. }) => {
// Strategy 1: Graceful termination
show_desync_error_to_user(frame);
disconnect_and_return_to_menu();
// Strategy 2: Log and continue (debugging)
log::error!("Desync detected at frame {}", frame);
// Continue running to gather more diagnostic data
// Strategy 3: Competitive anti-cheat
report_to_server(peer_handle, frame);
mark_match_as_invalid();
}
_ => {}
}
Configuration¶
Adjusting Detection Interval¶
use fortress_rollback::DesyncDetection;
// Default: once per second at 60fps
SessionBuilder::<GameConfig>::new()
.with_desync_detection_mode(DesyncDetection::On { interval: 60 })
// ...
// Competitive: 6 times per second (tighter detection)
SessionBuilder::<GameConfig>::new()
.with_desync_detection_mode(DesyncDetection::On { interval: 10 })
// ...
// Disabled (not recommended for production)
SessionBuilder::<GameConfig>::new()
.with_desync_detection_mode(DesyncDetection::Off)
// ...
| Interval | Checks/sec @ 60fps | Latency to Detect | Use Case |
|---|---|---|---|
| 10 | 6 | ~166ms | Competitive, anti-cheat |
| 30 | 2 | ~500ms | Responsive detection |
| 60 | 1 | ~1s | Default, balanced |
| 120 | 0.5 | ~2s | Low-overhead |
| 300 | 0.2 | ~5s | Development testing |
Troubleshooting¶
"NotSynchronized" Error¶
Cause: Trying to advance frame before synchronization completes.
Fix: Check session.current_state() == SessionState::Running before adding input or advancing.
Desync Detected¶
Cause: Non-deterministic game simulation.
Debug:
- Use
SyncTestSessionto reproduce locally - Check for HashMap usage, random numbers, floating-point edge cases
- Ensure all clients run identical code
- Verify all state is saved/loaded correctly
Connection Timeout¶
Cause: Network issues or firewall blocking UDP.
Fix:
- Verify both peers can reach each other
- Check firewalls allow UDP on your port
- Increase
disconnect_timeoutfor flaky connections - Ensure
poll_remote_clients()is called frequently
Rollbacks Too Frequent¶
Cause: High latency or low input delay.
Fix:
- Increase
with_input_delay() - Consider using sparse saving if saves are slow
- Check network quality
Game Stutters¶
Cause: Variable frame times or slow save/load.
Fix:
- Use fixed timestep game loop
- Profile save/load operations
- Consider sparse saving mode
- Ensure
advance_frame()completes quickly
"Input dropped" / NULL_FRAME returned¶
Cause: Input provided for wrong frame or out of sequence.
Fix:
- Always add input for current frame only
- Don't skip frames when adding input
- Check you're not calling
add_local_inputmultiple times per frame
Complete Configuration Reference¶
This section documents all configuration options available when building a session.
SessionBuilder Methods¶
| Method | Default | Description |
|---|---|---|
with_num_players(n) |
2 | Number of active players (not spectators) |
with_input_delay(frames) |
0 | Frames of input delay for local players |
with_max_prediction_window(frames) |
8 | Max frames ahead without confirmed inputs (0 = lockstep) |
with_fps(fps) |
60 | Expected frames per second for timing |
with_save_mode(mode) |
EveryFrame |
How often to save state for rollback |
with_desync_detection_mode(mode) |
On { interval: 60 } |
Checksum comparison between peers |
with_disconnect_timeout(duration) |
2000ms | Time before disconnecting unresponsive peer |
with_disconnect_notify_delay(duration) |
500ms | Time before warning about potential disconnect |
with_disconnect_behavior(behavior) |
Halt |
Action on auto-timeout: Halt (legacy) or ContinueWithout (graceful drop) |
with_check_distance(frames) |
2 | Frames to resimulate in SyncTestSession |
with_violation_observer(observer) |
None | Custom observer for spec violations |
add_player(type, handle) |
— | Register a player (local, remote, or spectator) |
add_local_player(handle) |
— | Convenience: add a local player by handle index |
add_remote_player(handle, addr) |
— | Convenience: add a remote player by handle index and address |
with_sync_config(config) |
SyncConfig::default() |
Synchronization protocol settings |
with_protocol_config(config) |
ProtocolConfig::default() |
Network protocol behavior |
with_spectator_config(config) |
SpectatorConfig::default() |
Spectator session behavior |
with_time_sync_config(config) |
TimeSyncConfig::default() |
Time synchronization averaging |
with_input_queue_config(config) |
InputQueueConfig::default() |
Input queue buffer sizing |
with_event_queue_size(size) |
100 | Maximum buffered events before dropping |
with_max_frames_behind(frames) |
10 | When spectator starts catching up |
with_catchup_speed(speed) |
1 | Frames per step when spectator catches up |
with_sparse_saving_mode(bool) |
false |
Deprecated: use with_save_mode() instead |
with_lan_defaults() |
— | Preset: LAN-optimized config (SyncConfig::lan + ProtocolConfig::competitive + TimeSyncConfig::lan) |
with_internet_defaults() |
— | Preset: Internet-optimized config (defaults + input delay 2) |
with_high_latency_defaults() |
— | Preset: Mobile/high-latency config (mobile presets + input delay 4) |
SyncConfig (Synchronization Protocol)¶
Configure the initial connection handshake with with_sync_config():
use fortress_rollback::SyncConfig;
use web_time::Duration;
let config = SyncConfig {
num_sync_packets: 5, // Roundtrips required (default: 5)
sync_retry_interval: Duration::from_millis(200), // Retry interval (default: 200ms)
sync_timeout: None, // Optional timeout (default: None)
running_retry_interval: Duration::from_millis(200), // Input retry interval (default: 200ms)
keepalive_interval: Duration::from_millis(200), // Keepalive interval (default: 200ms)
..Default::default() // Forward compatibility
};
Presets:
SyncConfig::default()- Balanced for typical internetSyncConfig::lan()- Fast sync for local networks (3 packets, 100ms intervals)SyncConfig::high_latency()- Tolerant for 100-200ms RTT (400ms intervals)SyncConfig::lossy()- Reliable for 5-15% packet loss (8 packets)SyncConfig::mobile()- High tolerance for variable mobile networks (10 packets, 350ms)SyncConfig::competitive()- Fast sync with strict timeouts (4 packets, 100ms, 3s timeout)SyncConfig::extreme()- Extreme burst loss survival (20 packets, 250ms, 30s timeout)SyncConfig::stress_test()- Automated testing only (40 packets, 150ms, 60s timeout)
ProtocolConfig (Network Protocol)¶
Configure network behavior with with_protocol_config():
use fortress_rollback::ProtocolConfig;
use web_time::Duration;
let config = ProtocolConfig {
quality_report_interval: Duration::from_millis(200), // RTT measurement interval
shutdown_delay: Duration::from_millis(5000), // Cleanup delay after disconnect
max_checksum_history: 32, // Checksums retained for desync
pending_output_limit: 128, // Warning threshold for output queue
sync_retry_warning_threshold: 10, // Warn after N sync retries
sync_duration_warning_ms: 3000, // Warn if sync takes longer
clock: None, // None = system clock (see below)
..Default::default()
};
The clock field accepts an Option<ClockFn> for injecting a custom time source. When None (the default), the protocol uses Instant::now(). See Custom Clock (Time Control) for details and test examples.
Presets:
ProtocolConfig::default()- General purposeProtocolConfig::competitive()- Fast quality reports (100ms), short shutdownProtocolConfig::high_latency()- Tolerant thresholds, longer timeoutsProtocolConfig::debug()- Low thresholds to observe telemetry easilyProtocolConfig::mobile()- Tolerant for mobile/cellular networks (350ms reports, large buffers)ProtocolConfig::deterministic(seed)- Fixed RNG seed for reproducible sessions; combine withclock: Some(clock_fn)for full determinism (controlled time + fixed RNG)
TimeSyncConfig (Time Synchronization)¶
Configure frame advantage averaging with with_time_sync_config():
use fortress_rollback::TimeSyncConfig;
let config = TimeSyncConfig {
window_size: 30, // Frames to average (default: 30)
};
Presets:
TimeSyncConfig::default()- 30-frame window (0.5s at 60 FPS)TimeSyncConfig::responsive()- 15-frame window (faster adaptation)TimeSyncConfig::smooth()- 60-frame window (more stable)TimeSyncConfig::lan()- 10-frame window (for stable LAN)TimeSyncConfig::mobile()- 90-frame window (smooths mobile jitter)TimeSyncConfig::competitive()- 20-frame window (fast adaptation, assumes stable network)
SpectatorConfig (Spectator Sessions)¶
Configure spectator behavior with with_spectator_config():
use fortress_rollback::SpectatorConfig;
let config = SpectatorConfig {
buffer_size: 60, // Input buffer size in frames (default: 60)
catchup_speed: 1, // Frames per step when catching up (default: 1)
max_frames_behind: 10, // When to start catching up (default: 10)
..Default::default()
};
Presets:
SpectatorConfig::default()- 60-frame buffer, no aggressive catchupSpectatorConfig::fast_paced()- 90-frame buffer, 2x catchup speedSpectatorConfig::slow_connection()- 120-frame buffer, tolerantSpectatorConfig::local()- 30-frame buffer, 2x catchup (minimal latency)SpectatorConfig::broadcast()- 180-frame buffer, smooth streaming for broadcastsSpectatorConfig::mobile()- 120-frame buffer, tolerant for mobile networks
InputQueueConfig (Input Buffer)¶
Configure input queue size with with_input_queue_config():
use fortress_rollback::InputQueueConfig;
let config = InputQueueConfig {
queue_length: 128, // Circular buffer size (default: 128)
};
Presets:
InputQueueConfig::default()- 128 frames (~2.1s at 60 FPS)InputQueueConfig::high_latency()- 256 frames (~4.3s at 60 FPS)InputQueueConfig::minimal()- 32 frames (~0.5s at 60 FPS)InputQueueConfig::standard()- Same as default (128 frames)
Note: Maximum input delay is queue_length - 1. Call with_input_queue_config() before with_input_delay() to ensure validation uses the correct limit.
SaveMode (State Saving)¶
Configure how states are saved with with_save_mode():
use fortress_rollback::SaveMode;
// Default: save every frame for minimal rollback distance
builder.with_save_mode(SaveMode::EveryFrame);
// Sparse: only save confirmed frames (fewer saves, longer rollbacks)
builder.with_save_mode(SaveMode::Sparse);
DesyncDetection¶
Configure checksum comparison with with_desync_detection_mode():
use fortress_rollback::DesyncDetection;
// Default: enabled with interval 60 (once per second at 60fps)
// No configuration needed unless you want to change it
// Tighter detection for competitive games
builder.with_desync_detection_mode(DesyncDetection::On { interval: 10 });
// Disable for performance benchmarking (not recommended for production)
builder.with_desync_detection_mode(DesyncDetection::Off);
Error Handling¶
Fortress Rollback uses FortressError for all error conditions. The enum is exhaustive, so you can write complete matches without wildcard arms and the compiler will notify you if new variants are added in future versions.
Error Types¶
| Error | Cause | Recovery |
|---|---|---|
PredictionThreshold |
Too far ahead without confirmed inputs | Wait for network to catch up; skip this frame's input |
NotSynchronized |
Session not yet synchronized | Keep polling; check SessionState::Running before operations |
InvalidRequest { info } |
Invalid API usage | Fix code; this is a programming error |
InvalidPlayerHandle { handle, max_handle } |
Handle out of range | Use handles 0 to num_players-1 |
InvalidFrame { frame, reason } |
Frame number invalid | Check frame is in valid range |
MissingInput { player_handle, frame } |
Required input not available | Ensure inputs are added before advancing |
MismatchedChecksum { current_frame, mismatched_frames } |
Desync in SyncTestSession | Debug non-determinism |
SpectatorTooFarBehind |
Spectator can't catch up | Reconnect spectator |
SerializationError { context } |
Serialization failed | Check input/state serialization |
SocketError { context } |
Network socket error | Check network, retry connection |
InternalError { context } |
Library bug | Please report! |
Error Handling Patterns¶
use fortress_rollback::FortressError;
fn handle_error(error: FortressError) -> Action {
match error {
// Recoverable: wait and retry
FortressError::PredictionThreshold => Action::WaitAndRetry,
FortressError::NotSynchronized => Action::KeepPolling,
// Recoverable: reconnect
FortressError::SpectatorTooFarBehind => Action::Reconnect,
FortressError::SocketError { .. } => Action::Reconnect,
// Desync: log and investigate
FortressError::MismatchedChecksum { current_frame, mismatched_frames } => {
eprintln!("Desync at frame {}: {:?}", current_frame, mismatched_frames);
Action::DesyncDetected
}
// Invalid requests: likely programming errors in application code
// Log for debugging, then signal fatal error (let app decide how to handle)
FortressError::InvalidRequest { info } => {
eprintln!("Invalid request (likely programming error): {info}");
Action::Fatal
}
FortressError::InvalidPlayerHandle { handle, max_handle } => {
eprintln!("Invalid player handle {handle} (max: {max_handle}) — check player setup");
Action::Fatal
}
FortressError::InvalidFrame { frame, reason } => {
eprintln!("Invalid frame {}: {}", frame, reason);
Action::Continue
}
// Frame arithmetic errors: typically from overflow in frame calculations
FortressError::FrameArithmeticOverflow { .. } => {
eprintln!("Frame arithmetic overflow — frame calculation exceeded limits");
Action::Fatal
}
FortressError::FrameValueTooLarge { .. } => {
eprintln!("Frame value too large — conversion from usize failed");
Action::Fatal
}
FortressError::MissingInput { player_handle, frame } => {
eprintln!("Missing input for player {} at frame {}", player_handle, frame);
Action::Continue
}
// Fatal errors
FortressError::SerializationError { context } => {
eprintln!("Serialization error: {}", context);
Action::Fatal
}
FortressError::InternalError { context } => {
eprintln!("Internal error (please report): {}", context);
Action::Fatal
}
// Structured variants (preferred for new code)
FortressError::InvalidFrameStructured { frame, reason } => {
eprintln!("Invalid frame {}: {:?}", frame, reason);
Action::Continue
}
FortressError::InternalErrorStructured { kind } => {
eprintln!("Internal error (please report): {:?}", kind);
Action::Fatal
}
FortressError::InvalidRequestStructured { kind } => {
eprintln!("Invalid request (likely programming error): {kind:?}");
Action::Fatal
}
FortressError::SerializationErrorStructured { kind } => {
eprintln!("Serialization error: {:?}", kind);
Action::Fatal
}
FortressError::SocketErrorStructured { kind } => {
eprintln!("Socket error: {:?}", kind);
Action::Reconnect
}
}
}
Waiting for Synchronization¶
use fortress_rollback::SessionState;
use web_time::{Duration, Instant};
fn wait_for_sync<C: Config>(
session: &mut P2PSession<C>,
timeout: Duration,
) -> Result<(), FortressError> {
let start = Instant::now();
while session.current_state() != SessionState::Running {
if start.elapsed() > timeout {
return Err(FortressError::NotSynchronized);
}
session.poll_remote_clients();
std::thread::sleep(Duration::from_millis(16));
}
Ok(())
}
Handling Prediction Threshold¶
fn add_input_safe<C: Config>(
session: &mut P2PSession<C>,
handle: PlayerHandle,
input: C::Input,
) -> bool {
match session.add_local_input(handle, input) {
Ok(()) => true,
Err(FortressError::PredictionThreshold) => {
// Too far ahead - skip this frame's input
// The game will catch up via rollback
false
}
Err(e) => {
eprintln!("Input error: {}", e);
false
}
}
}
Specification Violations (Telemetry)¶
Fortress Rollback includes a telemetry system for monitoring internal specification violations. These are issues that don't necessarily cause errors but indicate unexpected behavior.
Violation Severity Levels¶
| Severity | Description | Action |
|---|---|---|
Warning |
Unexpected but recoverable | Monitor; may indicate network issues |
Error |
Serious issue, degraded behavior | Investigate; may affect gameplay |
Critical |
Invariant broken, state may be corrupt | Debug immediately |
Violation Categories (ViolationKind)¶
| Kind | Description |
|---|---|
FrameSync |
Frame counter mismatch or unexpected frame values |
InputQueue |
Gap in input sequence, double-confirmation |
StateManagement |
Loading non-existent state, checksum issues |
NetworkProtocol |
Unexpected message, protocol state errors |
ChecksumMismatch |
Local/remote checksum difference |
Configuration |
Invalid parameter combinations |
InternalError |
Library bugs (please report) |
Invariant |
Runtime invariant check failed |
Synchronization |
Excessive sync retries, slow sync |
Setting Up a Violation Observer¶
use fortress_rollback::{
SessionBuilder, Config,
telemetry::{ViolationObserver, CollectingObserver, SpecViolation},
};
use std::sync::Arc;
// For testing: collect violations for assertions
let observer = Arc::new(CollectingObserver::new());
let session = SessionBuilder::<MyConfig>::new()
.with_num_players(2)?
.with_violation_observer(observer.clone())
// ... other config
.start_p2p_session(socket)?;
// After operations, check for violations
if !observer.is_empty() {
for violation in observer.violations() {
eprintln!("Violation: {}", violation);
}
}
Important: CollectingObserver accumulates violations indefinitely. Call observer.clear() periodically to prevent unbounded memory growth in long-running sessions:
// Check and clear violations periodically (e.g., every N frames)
if frame % 1000 == 0 {
let violations = observer.violations();
if !violations.is_empty() {
log::warn!("Found {} violations in last 1000 frames", violations.len());
observer.clear();
}
}
Custom Violation Observer¶
use fortress_rollback::telemetry::{ViolationObserver, SpecViolation, ViolationSeverity};
struct MetricsObserver {
// Your metrics client
}
impl ViolationObserver for MetricsObserver {
fn on_violation(&self, violation: &SpecViolation) {
// Send to metrics system
match violation.severity {
ViolationSeverity::Warning => {
// Increment warning counter
}
ViolationSeverity::Error | ViolationSeverity::Critical => {
// Alert on-call
}
}
}
}
Interpreting Common Violations¶
Synchronization violations (excessive retries):
This indicates packet loss during connection. Consider using SyncConfig::lossy().
Frame sync violations:
This can occur during initialization edge cases. Usually recovers automatically.
Input queue violations:
Indicates network issues causing missed inputs. May cause prediction errors.
Default Behavior¶
If no observer is set, violations are logged via the tracing crate:
Warning→tracing::warn!Error→tracing::error!Critical→tracing::error!with additional context
Enable tracing to see violations:
Structured Output for Log Aggregation¶
The TracingObserver outputs all fields as structured tracing fields, making it compatible with JSON logging formatters and log aggregation systems:
// Example: Using tracing-subscriber with JSON output
use tracing_subscriber::fmt::format::FmtSpan;
tracing_subscriber::fmt()
.json() // Enable JSON output
.init();
This produces machine-parseable output like:
{"timestamp":"2024-01-15T12:00:00Z","level":"WARN","severity":"warning","kind":"frame_sync","location":"sync.rs:42","frame":"100","context":"{expected=50, actual=100}","message":"frame mismatch"}
JSON Serialization for Programmatic Access¶
All telemetry types implement serde::Serialize for direct JSON serialization:
use fortress_rollback::telemetry::{SpecViolation, ViolationSeverity, ViolationKind};
use fortress_rollback::Frame;
let violation = SpecViolation::new(
ViolationSeverity::Warning,
ViolationKind::FrameSync,
"frame mismatch detected",
"sync.rs:42",
).with_frame(Frame::new(100))
.with_context("expected", "50")
.with_context("actual", "100");
// Direct JSON serialization
let json = serde_json::to_string(&violation)?;
// Or use the convenience method (returns Option<String>)
let json = violation.to_json();
let json_pretty = violation.to_json_pretty();
# Ok::<(), Box<dyn std::error::Error>>(())
Example JSON output:
{
"severity": "warning",
"kind": "frame_sync",
"message": "frame mismatch detected",
"location": "sync.rs:42",
"frame": 100,
"context": {
"actual": "100",
"expected": "50"
}
}
Key serialization behaviors:
severityandkindare serialized as snake_case strings ("warning","frame_sync")frameis serialized as an integer for valid frames, ornullforNone/Frame::NULLcontextis serialized as a JSON object with string keys and values
Next Steps¶
- Read the Architecture Guide for deeper understanding
- Check the examples in
examples/ex_game/for working code - See
examples/configuration.rsfor configuration patterns - See
examples/error_handling.rsfor error handling patterns - Join the GGPO Discord for community support
- File issues at the project repository
Happy rollback networking!