Skip to main content

Save/Load Game State

Persisting and Restoring Game Progress

Save systems preserve player progress and game state! Learn how to serialize game data, manage save files, handle different save formats, and create robust save/load systems that never lose player data! 💾🎮

Understanding Save Systems

📷 The Photo Album Analogy

Think of save systems like organizing photo albums:

graph TD A["Save System"] --> B["Data Collection"] A --> C["Serialization"] A --> D["Storage"] A --> E["Loading"] B --> F["Player State"] B --> G["World State"] B --> H["Game Progress"] C --> I["JSON"] C --> J["Binary"] C --> K["Custom Format"] D --> L["Local Storage"] D --> M["Cloud Save"] D --> N["Database"] E --> O["Validation"] E --> P["Deserialization"] E --> Q["State Restoration"]
JSON tree of a save file showing top-level metadata (version, timestamp, play_time) and three nested sections — player (name, level, health, position, inventory), progress (current_level, completed, score, achievements), and world (world_seed, enemies, items). A dashed callout highlights how Python tuples become JSON arrays during asdict() serialization.
Inside one save slot: top-level metadata wraps three logical sections — player, progress, and world. Python types like tuples flatten to JSON primitives on save and rehydrate back to Python objects on load.

Interactive Save/Load System Demo

Build a game state and test saving/loading!

Score: 0 | Level: 1 | Items: 0

Position: (300, 200) | Enemies: 0

Achievements: 0 | Play Time: 0:00

Basic Save/Load Implementation

import json
import os
import time
from typing import Optional
from dataclasses import dataclass, asdict, field

@dataclass
class SaveData:
    """Schema-as-contract: declare fields once; asdict / **data walk them automatically."""
    version: str = "1.0.0"
    timestamp: float = 0.0
    player_name: str = "Player"
    player_level: int = 1
    player_health: int = 100
    player_position: tuple = (0, 0)
    score: int = 0
    current_level: int = 1
    inventory: list = field(default_factory=list)

class SaveManager:
    """Atomic, validated save/load for one save directory."""
    def __init__(self, save_directory: str = "saves"):
        self.save_directory = save_directory
        os.makedirs(save_directory, exist_ok=True)

    def _filepath(self, slot: int) -> str:
        return os.path.join(self.save_directory, f"savegame_{slot}.json")

    def save_game(self, slot: int, save_data: SaveData) -> bool:
        """Atomic save: write tmp file, then os.replace into final path."""
        save_data.timestamp = time.time()
        final = self._filepath(slot)
        tmp = final + ".tmp"
        try:
            with open(tmp, "w") as f:
                json.dump(asdict(save_data), f, indent=2)
            os.replace(tmp, final)  # atomic on POSIX + Windows
            print(f"Game saved to slot {slot}")
            return True
        except OSError as e:
            print(f"Save failed: {e}")
            return False

    def load_game(self, slot: int) -> Optional[SaveData]:
        """Deserialize, then validate, then return — or None if either fails."""
        final = self._filepath(slot)
        if not os.path.exists(final):
            print(f"No save found in slot {slot}")
            return None
        try:
            with open(final) as f:
                data = json.load(f)
            save_data = SaveData(**data)
        except (OSError, json.JSONDecodeError, TypeError) as e:
            print(f"Load failed: {e}")
            return None
        if not self.validate_save(save_data):
            return None
        print(f"Game loaded from slot {slot}")
        return save_data

    def validate_save(self, save_data: SaveData) -> bool:
        """Reject corrupt / out-of-range / version-incompatible saves at the load boundary."""
        if save_data.version != "1.0.0":
            print(f"BLOCKED: incompatible version {save_data.version}")
            return False
        if save_data.player_health < 0:
            print(f"BLOCKED: player_health={save_data.player_health}")
            return False
        if save_data.current_level < 1:
            print(f"BLOCKED: current_level={save_data.current_level}")
            return False
        return True

    def list_saves(self) -> list:
        """List slot metadata across all saves in the directory."""
        saves = []
        for filename in os.listdir(self.save_directory):
            if not (filename.startswith("savegame_") and filename.endswith(".json")):
                continue
            try:
                with open(os.path.join(self.save_directory, filename)) as f:
                    data = json.load(f)
                slot = int(filename.split("_")[1].split(".")[0])
                saves.append({"slot": slot, "level": data.get("current_level", 1),
                              "score": data.get("score", 0), "timestamp": data.get("timestamp", 0)})
            except (OSError, ValueError, json.JSONDecodeError):
                continue
        return sorted(saves, key=lambda s: s["slot"])

# Save / load round-trip demo
if __name__ == "__main__":
    manager = SaveManager()
    state = SaveData(player_name="Hero", score=1500, current_level=3)
    manager.save_game(slot=1, save_data=state)
    loaded = manager.load_game(slot=1)
    print(f"Loaded: {loaded}")

Advanced Save Features

# Binary save system with compression
import zlib
import struct

class BinarySaveManager(SaveManager):
    """Binary save system with compression"""
    
    def save_binary(self, slot: int, save_data: SaveData) -> bool:
        """Save game data as compressed binary"""
        filename = f"savegame_{slot}.dat"
        filepath = os.path.join(self.save_directory, filename)
        
        try:
            # Serialize to binary
            binary_data = pickle.dumps(asdict(save_data))
            
            # Compress
            compressed = zlib.compress(binary_data, level=9)
            
            # Add header with version and checksum
            version = struct.pack('I', 1)  # Version 1
            checksum = struct.pack('I', zlib.crc32(compressed))
            size = struct.pack('I', len(compressed))
            
            # Write to file
            with open(filepath, 'wb') as f:
                f.write(b'SAVE')  # Magic number
                f.write(version)
                f.write(checksum)
                f.write(size)
                f.write(compressed)
            
            print(f"Binary save complete: {len(compressed)} bytes")
            return True
            
        except Exception as e:
            print(f"Binary save failed: {e}")
            return False
    
    def load_binary(self, slot: int) -> Optional[SaveData]:
        """Load game data from compressed binary"""
        filename = f"savegame_{slot}.dat"
        filepath = os.path.join(self.save_directory, filename)
        
        if not os.path.exists(filepath):
            return None
        
        try:
            with open(filepath, 'rb') as f:
                # Check magic number
                magic = f.read(4)
                if magic != b'SAVE':
                    print("Invalid save file format")
                    return None
                
                # Read header
                version = struct.unpack('I', f.read(4))[0]
                checksum = struct.unpack('I', f.read(4))[0]
                size = struct.unpack('I', f.read(4))[0]
                
                # Read compressed data
                compressed = f.read(size)
                
                # Verify checksum
                if zlib.crc32(compressed) != checksum:
                    print("Save file corrupted")
                    return None
                
                # Decompress
                binary_data = zlib.decompress(compressed)
                
                # Deserialize
                data = pickle.loads(binary_data)
                
                return SaveData(**data)
                
        except Exception as e:
            print(f"Binary load failed: {e}")
            return None

# Encrypted save system
from cryptography.fernet import Fernet

class EncryptedSaveManager(SaveManager):
    """Save system with encryption"""
    
    def __init__(self, save_directory: str = "saves"):
        super().__init__(save_directory)
        self.key = self.load_or_generate_key()
        self.cipher = Fernet(self.key)
    
    def load_or_generate_key(self) -> bytes:
        """Load or generate encryption key"""
        key_file = os.path.join(self.save_directory, ".key")
        
        if os.path.exists(key_file):
            with open(key_file, 'rb') as f:
                return f.read()
        else:
            key = Fernet.generate_key()
            with open(key_file, 'wb') as f:
                f.write(key)
            return key
    
    def save_encrypted(self, slot: int, save_data: SaveData) -> bool:
        """Save encrypted game data"""
        filename = f"savegame_{slot}.enc"
        filepath = os.path.join(self.save_directory, filename)
        
        try:
            # Serialize to JSON
            json_data = json.dumps(asdict(save_data))
            
            # Encrypt
            encrypted = self.cipher.encrypt(json_data.encode())
            
            # Save
            with open(filepath, 'wb') as f:
                f.write(encrypted)
            
            print(f"Encrypted save complete")
            return True
            
        except Exception as e:
            print(f"Encrypted save failed: {e}")
            return False
    
    def load_encrypted(self, slot: int) -> Optional[SaveData]:
        """Load encrypted game data"""
        filename = f"savegame_{slot}.enc"
        filepath = os.path.join(self.save_directory, filename)
        
        if not os.path.exists(filepath):
            return None
        
        try:
            with open(filepath, 'rb') as f:
                encrypted = f.read()
            
            # Decrypt
            decrypted = self.cipher.decrypt(encrypted)
            
            # Deserialize
            data = json.loads(decrypted.decode())
            
            return SaveData(**data)
            
        except Exception as e:
            print(f"Encrypted load failed: {e}")
            return None

# Cloud save system
class CloudSaveManager(SaveManager):
    """Cloud-based save system"""
    
    def __init__(self, save_directory: str = "saves", cloud_provider=None):
        super().__init__(save_directory)
        self.cloud_provider = cloud_provider  # AWS, Google Cloud, etc.
        self.sync_enabled = True
        self.last_sync = time.time()
    
    def sync_saves(self):
        """Sync local saves with cloud"""
        if not self.sync_enabled:
            return
        
        try:
            # Get local saves
            local_saves = self.list_saves()
            
            # Get cloud saves (pseudo-code)
            # cloud_saves = self.cloud_provider.list_saves()
            
            # Compare and sync
            for save in local_saves:
                # Upload newer local saves
                # self.cloud_provider.upload(save)
                pass
            
            # Download newer cloud saves
            # for cloud_save in cloud_saves:
            #     if cloud_save.newer_than_local():
            #         self.download_save(cloud_save)
            
            self.last_sync = time.time()
            print("Cloud sync complete")
            
        except Exception as e:
            print(f"Cloud sync failed: {e}")

# Save versioning and migration
class SaveMigration:
    """Handles save file version migrations"""
    
    @staticmethod
    def migrate(save_data: dict, from_version: str, to_version: str) -> dict:
        """Migrate save data between versions"""
        migrations = {
            ("1.0.0", "1.1.0"): SaveMigration.migrate_1_0_to_1_1,
            ("1.1.0", "2.0.0"): SaveMigration.migrate_1_1_to_2_0,
        }
        
        key = (from_version, to_version)
        if key in migrations:
            return migrations[key](save_data)
        
        # Handle multi-step migrations
        # ... migration chain logic ...
        
        return save_data
    
    @staticmethod
    def migrate_1_0_to_1_1(save_data: dict) -> dict:
        """Migrate from version 1.0.0 to 1.1.0"""
        # Add new fields with defaults
        save_data['new_feature'] = False
        save_data['version'] = "1.1.0"
        return save_data
    
    @staticmethod
    def migrate_1_1_to_2_0(save_data: dict) -> dict:
        """Migrate from version 1.1.0 to 2.0.0"""
        # Restructure data
        save_data['player'] = {
            'name': save_data.pop('player_name', 'Player'),
            'level': save_data.pop('player_level', 1),
            'health': save_data.pop('player_health', 100)
        }
        save_data['version'] = "2.0.0"
        return save_data

Save System Integration

import pygame
from dataclasses import dataclass, asdict, field

class GameWithSaveSystem:
    """Game with integrated save/load system"""
    def __init__(self):
        pygame.init()
        self.screen = pygame.display.set_mode((800, 600))
        pygame.display.set_caption("Game with Save System")
        self.clock = pygame.time.Clock()
        self.running = True
        
        # Initialize save manager
        self.save_manager = SaveManager()
        
        # Game state
        self.player = Player()
        self.enemies = []
        self.items = []
        self.score = 0
        self.current_level = 1
        self.play_time = 0
        
        # Load last save on startup
        self.load_last_save()
    
    def load_last_save(self):
        """Load most recent save on startup"""
        saves = self.save_manager.list_saves()
        if saves:
            # Load most recent save
            most_recent = max(saves, key=lambda x: x['timestamp'])
            save_data = self.save_manager.load_game(most_recent['slot'])
            if save_data:
                self.restore_game_state(save_data)
    
    def collect_game_state(self) -> SaveData:
        """Collect current game state for saving"""
        save_data = SaveData()
        
        # Player data
        save_data.player_name = self.player.name
        save_data.player_level = self.player.level
        save_data.player_health = self.player.health
        save_data.player_position = (self.player.x, self.player.y)
        save_data.player_inventory = self.player.inventory.copy()
        
        # Game progress
        save_data.current_level = self.current_level
        save_data.score = self.score
        save_data.play_time = self.play_time
        
        # World state
        save_data.enemies = [enemy.to_dict() for enemy in self.enemies]
        save_data.items = [item.to_dict() for item in self.items]
        
        return save_data
    
    def restore_game_state(self, save_data: SaveData):
        """Restore game state from save data"""
        # Player data
        self.player.name = save_data.player_name
        self.player.level = save_data.player_level
        self.player.health = save_data.player_health
        self.player.x, self.player.y = save_data.player_position
        self.player.inventory = save_data.player_inventory.copy()
        
        # Game progress
        self.current_level = save_data.current_level
        self.score = save_data.score
        self.play_time = save_data.play_time
        
        # World state
        self.enemies = [Enemy.from_dict(e) for e in save_data.enemies]
        self.items = [Item.from_dict(i) for i in save_data.items]
        
        print("Game state restored")
    
    def handle_input(self):
        """Handle input including save/load keys"""
        keys = pygame.key.get_pressed()
        
        # Quick save (F5)
        if keys[pygame.K_F5]:
            save_data = self.collect_game_state()
            self.save_manager.quick_save()
        
        # Quick load (F9)
        if keys[pygame.K_F9]:
            save_data = self.save_manager.quick_load()
            if save_data:
                self.restore_game_state(save_data)
        
        # Save slots (Ctrl+1-9)
        if keys[pygame.K_LCTRL]:
            for i in range(1, 10):
                if keys[pygame.K_0 + i]:
                    save_data = self.collect_game_state()
                    self.save_manager.save_game(i, save_data)
        
        # Load slots (Alt+1-9)
        if keys[pygame.K_LALT]:
            for i in range(1, 10):
                if keys[pygame.K_0 + i]:
                    save_data = self.save_manager.load_game(i)
                    if save_data:
                        self.restore_game_state(save_data)
    
    def update(self, dt):
        """Update game and check for auto-save"""
        # Update play time
        self.play_time += dt
        
        # Check auto-save
        if self.save_manager.auto_save():
            save_data = self.collect_game_state()
            self.save_manager.current_save_data = save_data
            print("Auto-saved")
        
        # Update game
        self.player.update(dt)
        for enemy in self.enemies:
            enemy.update(dt)
    
    def draw_save_menu(self):
        """Draw save/load menu overlay"""
        # Draw transparent background
        overlay = pygame.Surface((800, 600))
        overlay.set_alpha(200)
        overlay.fill((0, 0, 0))
        self.screen.blit(overlay, (0, 0))
        
        # Draw save slots
        font = pygame.font.Font(None, 36)
        saves = self.save_manager.list_saves()
        
        y = 100
        for i in range(1, 4):
            # Find save for this slot
            save = next((s for s in saves if s['slot'] == i), None)
            
            if save:
                # Format save info
                timestamp = datetime.fromtimestamp(save['timestamp'])
                text = f"Slot {i}: Level {save['level']} - Score {save['score']} - {timestamp.strftime('%Y-%m-%d %H:%M')}"
            else:
                text = f"Slot {i}: Empty"
            
            rendered = font.render(text, True, (255, 255, 255))
            self.screen.blit(rendered, (50, y))
            y += 50
    
    def run(self):
        """Main game loop"""
        dt = 0
        
        while self.running:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    # Save before quitting
                    save_data = self.collect_game_state()
                    self.save_manager.save_game(99, save_data)  # Exit save
                    self.running = False
            
            self.handle_input()
            self.update(dt)
            
            # Draw game
            self.screen.fill((20, 20, 30))
            self.player.draw(self.screen)
            
            # Draw UI
            self.draw_ui()
            
            pygame.display.flip()
            dt = self.clock.tick(60) / 1000.0

# Example classes for game objects
@dataclass
class Player:
    name: str = "Player"
    x: int = 400
    y: int = 300
    health: int = 100
    level: int = 1
    inventory: list = field(default_factory=list)

    def to_dict(self) -> dict:
        return asdict(self)

    @staticmethod
    def from_dict(data: dict) -> "Player":
        return Player(**data)

Best Practices

⚡ Save System Tips

Key Takeaways

🏋️‍♂️ Practice Exercise

🏋️‍♂️ Exercise 1: Round-Trip Save/Load + Validate-On-Load + Atomic-Write in One Pygame Window

Objective: Build a single-process pygame demo (~85 lines) that exercises three pillar save/load patterns from this lesson — @dataclass SaveData with asdict(state) on save and SaveData(**data) on load as the schema-as-contract, validate_save() as the design-time-vs-runtime gate at the load boundary, and write-temp-then-os.replace as crash-safety strict-ordering at the filesystem boundary — in one runnable pygame window where all three pillars are visible on screen.

Instructions:

  1. Define a @dataclass GameState holding version: str = "1.0.0", timestamp: float = 0, player_pos: tuple = (300, 200), score: int = 0, level: int = 1, inventory: list = None with a __post_init__ defaulting inventory to [] — the lesson's SaveData pattern at compact size for the demo. The dataclass IS the contract: asdict(state) serializes by walking declared fields, GameState(**data) deserializes by binding declared fields, and adding a new field updates both halves in lockstep with no separate save() / load() edits.
  2. Implement save_atomic(slot, state): set state.timestamp = time.time(), write json.dump(asdict(state), open(f'slot_{slot}.json.tmp', 'w')) to a temp file, then os.replace(tmp, f'slot_{slot}.json'). The atomic rename is the crash-safety guarantee — at any moment one of two states holds on disk: either the OLD save is intact at the final path, OR the NEW save is intact at the final path, NEVER a half-written final file.
  3. Implement load_validated(slot): read JSON via json.load, then call validate(data) checking data['version'] == '1.0.0' AND data['score'] >= 0 AND data['level'] >= 1 AND isinstance(data['inventory'], list). On any failure return None with a descriptive message ('BLOCKED: invalid version 0.9' / 'BLOCKED: score=-999 out of range'); on success return GameState(**data). Validation runs AFTER deserialize and BEFORE the live-state snap, so a corrupt save can never poison live state.
  4. Pygame window 800×480 with a 24×24 green player rect, WASD/arrow movement at 280 px/s, clamp_ip to screen bounds.
  5. Keys: 1/2/3 = save_atomic(slot, current_state) to slots 1/2/3 (HUD: 'saved slot N at HH:MM:SS'); 4/5/6 = load_validated(slot) for slots 1/2/3, and on success snap live state to the loaded GameState in one assignment — player_pos, score, level, inventory all replaced the SAME frame, the same snap-then-reapply ordering pattern from chat-52 M2 networking_client_server applied here at the persistence boundary instead of the network boundary; C = "corrupt slot 1" by writing {'version': '0.9', 'score': -999, 'level': 0, ...} directly to slot_1.json bypassing the atomic helper (simulates migration drift or external tampering), so the next press of 4 exercises the validation BLOCKED path; I = collect item (score += 100, inventory.append a string); R = reset live state to defaults.
  6. HUD shows: live state (player_pos / score / level / inventory length); per-slot file existence + last-modified timestamp; last save status; last load status (success or specific BLOCKED reason); a rolling 5-line log of save/load operations so the validation gate firing is visible per attempt — every signal feeding the round-trip is on screen, so the abstract schema-contract / validate-boundary / atomic-write shapes become concrete numbers per frame.
💡 Hint

Three orthogonal disciplines stacked at three different boundaries: the schema at the type level (the dataclass centralizes the contract; asdict and **data are the matching encode/decode pair), the validation at the load boundary (refuse to apply unsafe data BEFORE it touches live state), and the atomic write at the filesystem boundary (never leave a half-written file on crash). All three must hold simultaneously — losing the schema and save/load drift apart silently as fields are added; losing validation and a corrupt file poisons live state on the next load; losing atomicity and a power-loss mid-write destroys the only good save with no recovery.

✅ Example Solution
# Combine the lesson's SaveData / SaveManager / validate_save patterns
# with os.replace for atomic writes (Best Practices: 'Atomic Saves:
# Write to temp file first, then rename'). Snap-then-reapply at the
# load boundary mirrors chat-52 networking_client_server reconciliation.

🎯 Quick Quiz

Question 1: Why does the lesson's save/load architecture use a @dataclass SaveData with asdict(save_data) on save and SaveData(**data) on load — instead of manually building the dict field-by-field in save_game() and manually assigning each field in load_game()?

Question 2: Why does SaveManager.load_game deserialize the JSON file AND call validate_save(save_data) before returning the SaveData — rather than returning the deserialized object directly and letting downstream game code apply it to live state?

Question 3: Best Practices says “Atomic Saves: Write to temp file first, then rename.” Why is the write-temp-then-os.replace sequence the canonical save-write discipline rather than writing directly to the final filename and trusting modern hardware?

What's Next?

Congratulations! You've completed Section 1: Game Architecture! Next, we'll move on to Section 2: Networking and Multiplayer, where you'll learn to create connected gaming experiences!