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
- 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, 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, 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 - 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) }
# }
# 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) }
# }
# 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.
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 + 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 + 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.6", features = ["sync-send"] }
# Async server with Tokio
[dependencies]
fortress-rollback = { version = "0.6", features = ["sync-send", "tokio"] }
# Debugging production issues
[dependencies]
fortress-rollback = { version = "0.6", features = ["sync-send", "paranoid"] }
# Development with examples
[dependencies]
fortress-rollback = { version = "0.6", 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.6", 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);
}
}
}
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
};
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_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!