Module 17 of 20 Phase: Persistence & Player Progress

Save & Load Systems

A game that forgets everything the moment you close it feels unfinished. In this module you'll use Python's built-in json module to persist player progress, settings, and high scores to disk, build a sorted leaderboard that keeps only the best entries, and wire up a simple achievement-unlock system that checks conditions every frame.

📘
10 Lessons
This module
⏱️
~3.5 Hours
Estimated time
📊
Intermediate
Difficulty
🗂️
json
Primary library
Section 1

Why Games Need a Save System

Every game beyond a five-minute arcade toy needs some form of persistence. Players expect their progress, unlocked levels, best times, and preferences to survive a restart. Under the hood, "saving a game" almost always means one thing: taking data that lives in RAM (Python variables, dictionaries, objects) and writing it to a file on disk in a format that can be read back later.

Python's standard library ships with the json module specifically for this job. JSON (JavaScript Object Notation) is a lightweight, human-readable text format that maps almost perfectly onto Python's own dictionaries, lists, strings, numbers, and booleans. That means you can build a normal Python dict to represent your player's save data, and json.dump() will write it straight to a file with zero manual formatting work.

Compare this to alternatives: you could write your own custom text format and parse it by hand, but that's reinventing a solved problem and is far more error-prone. You could use Python's pickle module, which can serialize almost any Python object — but pickle files are binary, not human-readable, not safe to load from an untrusted source, and not portable to other languages if you ever port your game. For save files, settings, and high scores, JSON is the industry-standard sweet spot.

💡
Key insight: A save file is just a snapshot of state. Design your game so that all the data worth saving lives together in one place — a single dictionary or a small class with a to_dict() method — and the save/load code becomes trivial.
Section 2

Saving and Loading Player Progress with JSON

The core pattern is always the same: gather your game state into a dictionary, open a file in write mode, and hand the dictionary to json.dump(). Loading reverses the process with json.load(). Always wrap file operations in a try/except block — the save file might not exist yet on a player's very first launch, or it could be corrupted by a crash mid-write.

save_system.py
import json
import os

SAVE_FILE = "savegame.json"

def save_progress(player_data):
    """Write the player's current progress dict to disk as JSON."""
    with open(SAVE_FILE, "w") as f:
        json.dump(player_data, f, indent=2)
    print("Game saved.")

def load_progress():
    """Load progress from disk, or return sensible defaults if none exists."""
    if not os.path.exists(SAVE_FILE):
        return {
            "level": 1,
            "coins": 0,
            "unlocked_levels": [1],
            "player_name": "Player1",
            "checkpoint": None
        }
    try:
        with open(SAVE_FILE, "r") as f:
            return json.load(f)
    except (json.JSONDecodeError, OSError):
        print("Save file corrupted or unreadable — starting fresh.")
        return load_progress.__defaults__

# Example usage
progress = load_progress()
progress["coins"] += 50
progress["level"] = 3
save_progress(progress)

The indent=2 argument to json.dump() pretty-prints the file with line breaks and indentation — purely for human readability while you debug. It costs nothing at runtime and makes it trivial to open the save file in a text editor and verify its contents by eye.

Section 3

Checkpoints and Player Settings

Checkpoints are just another field in the same save dictionary — typically the level id plus a position (x, y) or a spawn-point identifier the level defines. When the player dies, respawn them at the last saved checkpoint instead of restarting the whole game.

Settings (volume, resolution, key bindings, difficulty) deserve their own file, separate from progress, because they're read once at startup before the main menu even loads and should never be reset by a "New Game" action. Keeping them in settings.json alongside — but distinct from — savegame.json avoids accidentally wiping a player's audio preferences when they start a new playthrough.

settings_manager.py
import json

SETTINGS_FILE = "settings.json"

DEFAULT_SETTINGS = {
    "music_volume": 0.7,
    "sfx_volume": 1.0,
    "fullscreen": False,
    "difficulty": "normal"
}

def load_settings():
    try:
        with open(SETTINGS_FILE, "r") as f:
            data = json.load(f)
            # merge with defaults so new settings added in updates aren't missing
            merged = DEFAULT_SETTINGS.copy()
            merged.update(data)
            return merged
    except (FileNotFoundError, json.JSONDecodeError):
        return DEFAULT_SETTINGS.copy()

def save_settings(settings):
    with open(SETTINGS_FILE, "w") as f:
        json.dump(settings, f, indent=2)

def set_checkpoint(player_data, level_id, x, y):
    """Update the checkpoint in the progress dict (call save_progress after)."""
    player_data["checkpoint"] = {"level": level_id, "x": x, "y": y}
    return player_data
⚠️
Common pitfall: If you add new fields to your settings dict in a later game update, an old settings.json written by a previous version won't have that key. The DEFAULT_SETTINGS.copy(); merged.update(data) pattern above solves this — defaults fill in gaps, and saved values override them.
Section 4

Building a Sorted High-Score Table

A high-score list is a list of dictionaries (name + score), sorted descending by score, with only the top N entries kept — usually 10. The trick is to always sort and trim after inserting a new score, so the file on disk never grows unbounded and always stays ready to display immediately.

highscores.py
import json
import os

SCORES_FILE = "highscores.json"
MAX_ENTRIES = 10

def load_highscores():
    if not os.path.exists(SCORES_FILE):
        return []
    with open(SCORES_FILE, "r") as f:
        return json.load(f)

def save_highscores(scores):
    with open(SCORES_FILE, "w") as f:
        json.dump(scores, f, indent=2)

def add_highscore(name, score):
    """Insert a new score, keep sorted descending, trim to MAX_ENTRIES."""
    scores = load_highscores()
    scores.append({"name": name, "score": score})
    scores.sort(key=lambda entry: entry["score"], reverse=True)
    scores = scores[:MAX_ENTRIES]
    save_highscores(scores)
    return scores

def is_high_score(score):
    """Check if a score would make it onto the leaderboard."""
    scores = load_highscores()
    if len(scores) < MAX_ENTRIES:
        return True
    return score > scores[-1]["score"]

# Example: player finishes a run with 4200 points
if is_high_score(4200):
    add_highscore("Ava", 4200)
StepWhat HappensWhy It Matters
1. AppendNew score added to the list in memorySimple, no need to find insertion index manually
2. Sortscores.sort(key=..., reverse=True)Guarantees descending order every time
3. TrimSlice to [:MAX_ENTRIES]File never grows unbounded
4. Savejson.dump() writes it backLeaderboard persists across sessions
Section 5

Achievement-Unlock Patterns

Achievements are conditions checked against game state — "collect 100 coins," "beat the boss without taking damage," "finish level 1 in under 60 seconds." The cleanest architecture defines each achievement as a dictionary with an id, a description, and a check function, then loops over all of them once per frame (or once per relevant event) comparing against the current player stats.

Unlocked achievement ids get saved to the progress file exactly like coins or levels — so once earned, they persist forever and never re-trigger their unlock popup.

achievements.py
ACHIEVEMENTS = [
    {"id": "coin_collector", "desc": "Collect 100 coins", "check": lambda stats: stats["coins"] >= 100},
    {"id": "speedrunner", "desc": "Finish Level 1 in under 60s", "check": lambda stats: stats.get("level1_time", 999) < 60},
    {"id": "untouchable", "desc": "Beat the boss without taking damage", "check": lambda stats: stats.get("boss_damage_taken", 1) == 0},
]

def check_achievements(player_data, stats):
    """Call every frame (or after key events) with current stats."""
    unlocked = set(player_data.get("achievements", []))
    newly_unlocked = []

    for ach in ACHIEVEMENTS:
        if ach["id"] not in unlocked and ach["check"](stats):
            unlocked.add(ach["id"])
            newly_unlocked.append(ach)

    player_data["achievements"] = list(unlocked)
    return newly_unlocked  # show popups for these

# Example: after collecting a coin
stats = {"coins": 100, "level1_time": 45, "boss_damage_taken": 1}
for ach in check_achievements(progress, stats):
    print(f"🏆 Achievement unlocked: {ach['desc']}")
🗄️
One save, many concerns
Progress, checkpoints, and achievements can all live in the same savegame.json dict — separate top-level keys keep them organized without needing separate files.
🔁
Idempotent checks
Checking "already unlocked" before re-triggering an achievement avoids duplicate popups and keeps the check function safe to call every single frame.
Section 6

Anatomy of a Save File

Once player progress, settings and achievements all funnel into the same JSON document, it helps to see the shape of that document as a tree: one root object branching into a handful of independent top-level keys.

save_data.json player {} settings {} high_scores []
The whole save file is one JSON object with three independent top-level keys

🧠 Quick Knowledge Check

1. Which Python module is used in this module to save and load game data as readable text files?
2. Why should the save/load code wrap file access in try/except?
3. In the high-score system, what two operations run every time a new score is added, before saving?
4. Why keep settings.json separate from savegame.json?
5. In the achievement system, what prevents an already-unlocked achievement from re-triggering its popup?
Finished Module 17?

Mark it complete to track your progress through the Pygame Mastery course.

🗒 Cheat Sheet 📝 Worksheet