Save/Load System Implementation
This document provides technical details about the save/load system implementation in Rummage.
Architecture
The save/load system consists of several interconnected components:
- Plugin:
SaveLoadPlugin
handles registration of all events, resources, and systems. - Events: Events like
SaveGameEvent
andLoadGameEvent
trigger save and load operations. - Resources: Configuration and state tracking resources like
SaveConfig
andReplayState
. - Data Structures: Serializable data representations in the
data.rs
module. - Systems: Bevy systems for handling operations defined in
systems.rs
.
Data Model
The save/load system uses a comprehensive data model to capture the game state:
GameSaveData
The main structure that holds all serialized game data:
#![allow(unused)] fn main() { pub struct GameSaveData { pub game_state: GameStateData, pub players: Vec<PlayerData>, pub zones: ZoneData, pub commanders: CommanderData, pub save_version: String, } }
GameStateData
Core game state information:
#![allow(unused)] fn main() { pub struct GameStateData { pub turn_number: u32, pub active_player_index: usize, pub priority_holder_index: usize, pub turn_order_indices: Vec<usize>, pub lands_played: Vec<(usize, u32)>, pub main_phase_action_taken: bool, pub drawn_this_turn: Vec<usize>, pub eliminated_players: Vec<usize>, pub use_commander_damage: bool, pub commander_damage_threshold: u32, pub starting_life: i32, } }
PlayerData
Player-specific information:
#![allow(unused)] fn main() { pub struct PlayerData { pub id: usize, pub name: String, pub life: i32, pub mana_pool: ManaPool, pub player_index: usize, } }
ZoneData
Information about card zones and contents:
#![allow(unused)] fn main() { pub struct ZoneData { // Maps player indices to their libraries pub libraries: std::collections::HashMap<usize, Vec<usize>>, // Maps player indices to their hands pub hands: std::collections::HashMap<usize, Vec<usize>>, // Shared battlefield pub battlefield: Vec<usize>, // Maps player indices to their graveyards pub graveyards: std::collections::HashMap<usize, Vec<usize>>, // Shared exile zone pub exile: Vec<usize>, // Command zone pub command_zone: Vec<usize>, // Maps card indices to their current zone pub card_zone_map: std::collections::HashMap<usize, Zone>, } }
CommanderData
Commander-specific data:
#![allow(unused)] fn main() { pub struct CommanderData { // Maps player indices to their commander indices pub player_commanders: std::collections::HashMap<usize, Vec<usize>>, // Maps commander indices to their current zone pub commander_zone_status: std::collections::HashMap<usize, CommanderZoneLocation>, // Tracks how many times a commander has moved zones pub zone_transition_count: std::collections::HashMap<usize, u32>, } }
bevy_persistent Integration
The save/load system uses bevy_persistent
for robust persistence. This implementation provides:
- Format Selection: Currently uses
Bincode
for efficient binary serialization. - Path Selection: Appropriate paths based on platform (native or web) and user configuration.
- Error Handling: Robust handling of failures during save/load operations with graceful fallbacks.
- Resource Management: Automatic resource persistence and loading.
Example integration from the setup_save_system
function with improved error handling:
#![allow(unused)] fn main() { // Create save directory if it doesn't exist let config = SaveConfig::default(); // Only try to create directory on native platforms #[cfg(not(target_arch = "wasm32"))] if let Err(e) = std::fs::create_dir_all(&config.save_directory) { error!("Failed to create save directory: {}", e); // Continue anyway - the directory might already exist } // Determine the appropriate base path for persistence based on platform let metadata_path = get_storage_path(&config, "metadata.bin"); // Initialize persistent save metadata with fallback options let save_metadata = match Persistent::builder() .name("save_metadata") .format(StorageFormat::Bincode) .path(metadata_path) .default(SaveMetadata::default()) .build() { Ok(metadata) => metadata, Err(e) => { error!("Failed to create persistent save metadata: {}", e); // Create a fallback in-memory resource Persistent::builder() .name("save_metadata") .format(StorageFormat::Bincode) .path(PathBuf::from("metadata.bin")) .default(SaveMetadata::default()) .build() .expect("Failed to create even basic metadata") } }; commands.insert_resource(config.clone()); commands.insert_resource(save_metadata); }
Configuration
The save system is configured through the SaveConfig
resource:
#![allow(unused)] fn main() { #[derive(Resource, Clone, Debug)] pub struct SaveConfig { pub save_directory: PathBuf, pub auto_save_enabled: bool, pub auto_save_frequency: usize, } }
This resource allows customizing:
- The directory where save files are stored
- Whether auto-saving is enabled
- How frequently auto-saves occur
Entity Mapping
One of the challenges in serializing Bevy's ECS is handling entity references. The save/load system solves this by:
- During Save: Converting entity references to indices using a mapping
- During Load: Recreating entities and building a reverse mapping
- After Load: Reconstructing relationships using the new entity handles
This approach ensures entity references remain valid across save/load cycles, even though the actual entity IDs change.
Replay System
The replay system extends save/load functionality by:
- Loading a saved game state
- Recording actions in a
ReplayAction
queue - Allowing step-by-step playback of recorded actions
- Providing controls to start, step through, and stop replays
Error Handling
The save/load system employs several error handling strategies:
- Corrupted Data: Graceful handling of corrupted saves with fallbacks to default values
- Missing Entities: Safe handling when mapped entities don't exist, including placeholder entities when needed
- Empty Player Lists: Special handling for saves with no players, preserving game state data
- Version Compatibility: Checking save version compatibility
- File System Errors: Robust handling of IO and persistence errors with appropriate error messages
- Directory Creation: Automatic creation of save directories with error handling and verification
- Save Verification: Verification that save files were actually created with appropriate delays
- Filesystem Synchronization: Added delays to ensure filesystem operations complete before verification
Example of handling corrupted entity mappings:
#![allow(unused)] fn main() { // If there's a corrupted mapping, fall back to basic properties if index_to_entity.is_empty() || index_to_entity.contains(&Entity::PLACEHOLDER) { // At minimum, restore basic properties not tied to player entities game_state.turn_number = save_data.game_state.turn_number; // For empty player list, set reasonable defaults for player-related fields if save_data.game_state.turn_order_indices.is_empty() { // Create a fallback turn order game_state.turn_order = VecDeque::new(); } } else { // Full restore with valid player entities **game_state = save_data.to_game_state(&index_to_entity); } }
Example of improved directory creation and save verification:
#![allow(unused)] fn main() { // Ensure save directory exists for native platforms #[cfg(not(target_arch = "wasm32"))] { if !config.save_directory.exists() { match std::fs::create_dir_all(&config.save_directory) { Ok(_) => info!("Created save directory: {:?}", config.save_directory), Err(e) => { error!("Failed to create save directory: {}", e); continue; // Skip this save attempt } } } } // ... saving process ... // Verify save file was created for native platforms #[cfg(not(target_arch = "wasm32"))] { // Wait a short time to ensure filesystem operations complete std::thread::sleep(std::time::Duration::from_millis(100)); if !save_path.exists() { error!("Save file was not created at: {:?}", save_path); continue; } else { info!("Verified save file exists at: {:?}", save_path); } } }
Testing
The save/load system includes comprehensive tests:
- Unit Tests: Testing individual components and functions
- Integration Tests: Testing full save/load cycles
- Edge Cases: Testing corrupted saves, empty data, etc.
- Platform-Specific Tests: Special considerations for WebAssembly
WebAssembly Support
For web builds, the save/load system:
- Uses browser local storage instead of the file system
- Handles storage limitations and permissions
- Uses appropriate path prefixes for the storage backend
See WebAssembly Local Storage for more details.
Performance Considerations
The save/load system is designed with performance in mind:
- Uses efficient binary serialization (Bincode)
- Avoids unnecessary re-serialization of unchanged data
- Performs heavy operations outside of critical game loops
- Uses compact data representations where possible
Future Improvements
Potential future enhancements:
- Incremental Saves: Only saving changes since the last save
- Save Compression: Optional compression for large save files
- Save Verification: Checksums or other validation of save integrity
- Multiple Save Formats: Support for JSON or other human-readable formats
- Cloud Integration: Syncing saves to cloud storage
Integration with Snapshot System
The save/load system is integrated with the snapshot system to enable visual differential testing of game states at different points in time or different steps in a replay.
Visual Differential Testing
Visual differential testing allows capturing renderings of a game state at specific points in a saved game or replay. These images can be compared to detect visual differences, regressions, or unexpected changes in the game's rendering.
Automatic Snapshots
The snapshot system automatically captures screenshots when:
- A game is saved (via
take_save_game_snapshot
system) - During replay, when steps are taken (via
take_replay_snapshot
system)
Manual Differential Testing
For more controlled testing, you can:
- Start a replay of a save file
- Press F10 at any point to capture the current state (via
capture_replay_at_point
system) - Continue stepping through the replay
- Press F10 again to capture another state for comparison
Programmatic Differential Testing
The capture_differential_game_snapshot
and compare_game_states
functions provide a programmatic way to perform visual differential testing:
#![allow(unused)] fn main() { // Compare turn 1 to turn 3 of a saved game let result = compare_game_states( &mut world, "my_save_game", (Some(1), None), // Turn 1 (Some(3), None), // Turn 3 ); match result { Some((reference_image, comparison_image, difference)) => { // Compare the images or save them for later comparison if difference > THRESHOLD { println!("Visual difference detected: {}%", difference * 100.0); } }, None => println!("Failed to capture comparison"), } }
SaveGameSnapshot Component
The integration uses the SaveGameSnapshot
component to link snapshots to specific saved games:
#![allow(unused)] fn main() { pub struct SaveGameSnapshot { /// The save slot name this snapshot is associated with pub slot_name: String, /// The turn number in the saved game pub turn_number: u32, /// Optional timestamp of when the snapshot was taken pub timestamp: Option<i64>, /// Optional description of the game state pub description: Option<String>, } }
Test Systems
The integration includes:
- Integration tests that verify snapshot events are triggered during save/load operations
- Functions to capture snapshots at specific points in a replay
- Comparison functions to detect visual differences between game states