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:
- Save Data: Individual photos capturing moments
- Save Slots: Different photo albums
- Serialization: Developing photos from negatives
- Deserialization: Viewing photos to remember
- Compression: Storing photos efficiently
- Versioning: Dating and organizing photos
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
- Atomic Saves: Write to temp file first, then rename
- Backup Saves: Keep multiple backup copies
- Version Control: Include version numbers for migration
- Data Validation: Always validate loaded data
- Compression: Compress large save files
- Encryption: Protect against save editing if needed
- Auto-Save: Save periodically and at checkpoints
- Save Thumbnails: Include screenshots for visual reference
Key Takeaways
- 💾 Save systems preserve player progress and game state
- 📝 Serialization converts game objects to storable formats
- 🔄 Deserialization restores game state from saved data
- 🗂️ Multiple save slots let players manage different playthroughs
- ⚡ Quick save/load provides convenient state management
- ☁️ Cloud saves enable cross-device play
- 🔒 Encryption protects save data integrity
- 📊 Save versioning handles game updates gracefully
🏋️♂️ 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:
- Define a
@dataclass GameStateholdingversion: str = "1.0.0",timestamp: float = 0,player_pos: tuple = (300, 200),score: int = 0,level: int = 1,inventory: list = Nonewith a__post_init__defaultinginventoryto[]— 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. - Implement
save_atomic(slot, state): setstate.timestamp = time.time(), writejson.dump(asdict(state), open(f'slot_{slot}.json.tmp', 'w'))to a temp file, thenos.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. - Implement
load_validated(slot): read JSON viajson.load, then callvalidate(data)checkingdata['version'] == '1.0.0'ANDdata['score'] >= 0ANDdata['level'] >= 1ANDisinstance(data['inventory'], list). On any failure returnNonewith a descriptive message ('BLOCKED: invalid version 0.9' / 'BLOCKED: score=-999 out of range'); on success returnGameState(**data). Validation runs AFTER deserialize and BEFORE the live-state snap, so a corrupt save can never poison live state. - Pygame window 800×480 with a 24×24 green player rect, WASD/arrow movement at 280 px/s,
clamp_ipto screen bounds. - 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 toslot_1.jsonbypassing 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. - 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!