Skip to main content

Lesson 10: Saving & Loading

  • Module 5: States, Scenes & Saves
  • Lesson 10 of 27
  • ⏱️ About 1 h 30 min (instruction + lab)

Players forgive a lot, but not losing an hour of progress. In this lesson you build a save system with three slots that survives crashes, refuses damaged files instead of crashing, and still loads saves made by older versions of your game.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Decide what belongs in a save file and describe it once as a @dataclass.
  • Save and load game state as JSON with asdict() and SaveData(**data), in numbered save slots.
  • Explain why writing to a temporary file and then calling os.replace protects a save from a crash halfway through.
  • Validate loaded data and report a damaged save instead of crashing.
  • Build a migration chain that upgrades version 1 and 2 saves to the current version without losing data.

Project: Save Slots, a coin-collecting game with three save slots, F5 to save and F9 to load.

In This Lesson

💾 What Goes in a Save?

A save file is like a photo of your game at one moment, with a note on the back saying what everything was. When you load it, the game doesn't restore the photo itself; it reads the note and rebuilds the scene. So a save holds facts: where the player stands, the score, how long they have played, which coins are still on the map. It never holds things the game can rebuild from those facts, such as images, fonts, Surfaces or sounds.

The JSON tree of one save slot: top-level metadata (version, timestamp, play time) above three nested sections, player, progress and world. A callout notes that Python tuples become JSON arrays when saved.
One save slot: some top-level metadata, then the player, their progress and the state of the world. Note the callout: JSON has no tuple type, so a tuple you save comes back as a list.

This course saves as JSON, a plain-text format that Python's built-in json module reads and writes. json.dump(data, file) turns dictionaries, lists, strings, numbers, True/False and None into text, and json.load(file) turns the text back. You can open a JSON save in any text editor, which makes debugging much easier.

with open("slot_1.json", "w", encoding="utf-8") as f:
    json.dump({"score": 12, "pos": (3, 4)}, f)

with open("slot_1.json", encoding="utf-8") as f:
    data = json.load(f)
print(data)          # {'score': 12, 'pos': [3, 4]}   the tuple came back as a list

Two JSON surprises to remember: tuples come back as lists, and dictionary keys always come back as strings ({1: "sword"} loads as {"1": "sword"}). You will convert both back by hand where it matters.

🧭 Why not pickle?

Python's pickle module can save almost any object in one line, which is tempting. But the Python documentation warns that it is not secure: loading a pickle file can run arbitrary code, so you must only unpickle data you trust. Save files get shared, downloaded and edited, so this course uses JSON, which can only ever produce plain data.

📋 A Save Schema with @dataclass

You could build the save dictionary by hand, field by field, and read it back field by field. Then one day you add play_time to the saving code, forget it in the loading code, and nobody notices for a week. Better: describe the save's fields exactly once, and let Python walk them in both directions.

Here is a complete round trip: build a save, write it, read it back, and check it came back equal. SaveData(**data) passes every key of the dictionary as a keyword argument, so the dataclass's fields are the contract in both directions.

import json
from dataclasses import asdict, dataclass, field
from pathlib import Path

SAVE_FILE = Path(__file__).parent / "round_trip.json"


@dataclass
class SaveData:
    version: int = 3
    player_x: float = 400.0
    player_y: float = 300.0
    score: int = 0
    play_time: float = 0.0
    coins: list = field(default_factory=list)     # [[x, y], ...]


original = SaveData(player_x=120.5, score=9, coins=[[10.0, 20.0], [30.0, 40.0]])
with open(SAVE_FILE, "w", encoding="utf-8") as f:
    json.dump(asdict(original), f, indent=2)       # indent makes the file easy to read

with open(SAVE_FILE, encoding="utf-8") as f:
    loaded = SaveData(**json.load(f))

print(loaded)
print("Same as before?", loaded == original)
SAVE_FILE.unlink()                                 # tidy up the demo file

Adding a field later is now a one-line change, and the dataclass protects you in both directions: a key missing from the file gets its default, and an unknown key makes SaveData(**data) raise TypeError, which you will treat as a damaged save.

🛡️ Atomic Writes and Save Slots

Opening a file with "w" empties it immediately. If the game crashes or the power goes out while the new save is being written, the old save is already gone and the new one is half-written: the player has nothing. The fix is to write the new save to a temporary file next to it, and only when that is complete, swap it into place with os.replace. The Python documentation describes a successful os.replace as an atomic operation on POSIX systems (Linux, macOS), and it replaces an existing file on Windows too. Until that one line runs, the old save is untouched.

SAVE_DIR = Path(__file__).parent / "saves"


def slot_path(slot, save_dir=SAVE_DIR):
    return Path(save_dir) / f"slot_{slot}.json"


def save_game(slot, save, save_dir=SAVE_DIR):
    """Write the save atomically: a temp file first, then one os.replace."""
    path = slot_path(slot, save_dir)
    tmp = path.with_name(path.name + ".tmp")
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(asdict(save), f, indent=2)
        os.replace(tmp, path)          # the old file stays intact until this line
    except OSError as error:
        return f"Save failed: {error}"
    return f"Saved slot {slot}"

Save slots are simply different file names: slot_1.json, slot_2.json, slot_3.json. The save_dir argument has a default, so the game never passes it, but a test can point it at a temporary folder. Notice the function reports a problem as a message instead of letting an OSError (disk full, folder not writable) crash the game.

💡 Why this matters

A crash while saving is rare, but a player who loses a 20-hour save to one tells everyone. Writing to a temp file and replacing costs you three extra lines.

🔍 Validate Everything You Load

A save file lives outside your program, where anything can happen to it: a crash in an older version of your game, a player editing it by hand, a sync tool that cut it short. Loading is a boundary, and the rule at a boundary is: check first, then trust. There are two checks. Can the file be read and turned into a SaveData at all? And are the values it contains sensible?

def is_number(value):
    return isinstance(value, (int, float))


def validate(save):
    """Return None if the save is safe to use, or a short reason if it is not."""
    numbers = (save.score, save.player_x, save.player_y, save.play_time)
    if not all(is_number(n) for n in numbers):     # "ten" < 0 would raise TypeError
        return "a number field holds something else"
    if save.score < 0:
        return f"score {save.score} is negative"
    if not (0 <= save.player_x <= WIDTH and 0 <= save.player_y <= HEIGHT):
        return "player is outside the world"
    if save.play_time < 0:
        return "play time is negative"
    if not isinstance(save.coins, list) or not all(
            isinstance(c, list) and len(c) == 2 and all(is_number(n) for n in c)
            for c in save.coins):
        return "coin list is damaged"
    return None


def load_game(slot, save_dir=SAVE_DIR):
    """Return (SaveData, message) on success, or (None, reason). Never raises."""
    path = slot_path(slot, save_dir)
    if not path.exists():
        return None, f"Slot {slot} is empty"
    try:
        with open(path, encoding="utf-8") as f:
            data = json.load(f)
        if not isinstance(data, dict):
            raise ValueError("not a save file")
        save = SaveData(**migrate(data))           # migrate() comes in the next section
    except (OSError, ValueError, TypeError) as error:   # JSONDecodeError is a ValueError
        return None, f"Slot {slot} is damaged: {error}"
    problem = validate(save)
    if problem is not None:
        return None, f"Slot {slot} rejected: {problem}"
    return save, f"Loaded slot {slot}"

validate checks each value's type before comparing it, because a hand-edited file can hold "ten" where a number belongs, and "ten" < 0 raises a TypeError instead of returning False. Two built-ins do the checking: isinstance(value, (int, float)) is True when value is an int or a float (a tuple of types means "any of these"), and all(...) is True only when every item it is given is true, the way any(...) is True when at least one is. Every way this can go wrong ends in the same place: (None, reason). The game shows the reason and carries on with the state it already had. A damaged save never gets half-applied to the live game, because nothing touches the game until load_game returns a SaveData that passed every check.

✅ Growth Mindset: Broken Files Are Part of the Job

The first time your own game crashes on its own save file, it feels like the whole system is wrong. It isn't: every shipped game meets damaged saves, and the difference is whether it crashes or says "Slot 1 is damaged". Test the unhappy paths on purpose. Delete half of a save file in a text editor, set the score to -999, add a field that doesn't exist, and watch your loader handle each one. Each failure you cause deliberately is one your players will never see.

🧬 Versioned Saves and Migrations

Your game will change after people start playing it. Version 1 stored the position as x and y. Version 2 renamed them and added a play timer. Version 3 remembers which coins are left. Players still have version 1 saves on their disks, and "your save is no longer compatible" is a sad message to read. So every save records its version, and for each old version you write one small migration function that upgrades a save by exactly one step.

graph LR A["version 1<br/>x, y, score"] -->|v1_to_v2| B["version 2<br/>player_x, player_y,<br/>score, play_time"] B -->|v2_to_v3| C["version 3<br/>+ coins"] C --> D["SaveData(**data)"]
SAVE_VERSION = 3                   # bump this whenever the save format changes


def v1_to_v2(data):
    """Version 1 called the position x and y and had no play time."""
    data = dict(data)                              # copy: never change the caller's dict
    data["player_x"] = data.pop("x", WIDTH / 2)
    data["player_y"] = data.pop("y", HEIGHT / 2)
    data["play_time"] = 0.0
    data["version"] = 2
    return data


def v2_to_v3(data):
    """Version 3 remembers the coins; older saves get a fresh set."""
    data = dict(data)
    data["coins"] = []
    data["version"] = 3
    return data


MIGRATIONS = {1: v1_to_v2, 2: v2_to_v3}


def migrate(data):
    """Upgrade a loaded dict step by step to SAVE_VERSION, or raise ValueError."""
    version = data.get("version", 1)
    if not isinstance(version, int):
        raise ValueError(f"bad version {version!r}")
    if version > SAVE_VERSION:
        raise ValueError(f"save is from a newer game (version {version})")
    while version < SAVE_VERSION:
        step = MIGRATIONS.get(version)
        if step is None:
            raise ValueError(f"no migration from version {version}")
        data = step(data)
        version = data["version"]
    return data

The rules that keep a migration chain healthy:

  • One step each. v1_to_v2 only knows about versions 1 and 2. When version 4 arrives you add v3_to_v4 and one dictionary entry; the old steps never change.
  • Carry everything forward. Each step copies the whole dictionary and changes only what its version changed. A migration that builds a brand-new dictionary with just the fields it knows about silently throws the rest away.
  • Never delete old steps. Someone, somewhere, still has a version 1 save.
  • Refuse the future. A save from a newer game than this one can't be understood, so it is a clear error, not a guess. A missing step is a clear error too, never a KeyError crash.

Because load_game calls migrate before building the SaveData and before validating, an old save passes through exactly the same checks as a new one.

🎮 Saving from the Game Loop

Saving is a single action, so it belongs on a key press event, not on a held key. pygame.key.get_pressed() is true on every frame the key is down: holding F5 for half a second at 60 FPS would write the file about 30 times.

for event in pygame.event.get():
    if event.type == pygame.QUIT:
        running = False
    elif event.type == pygame.KEYDOWN:              # one save per key press
        if event.key in (pygame.K_1, pygame.K_2, pygame.K_3):
            slot = event.key - pygame.K_0           # K_1 - K_0 == 1, and so on
        elif event.key == pygame.K_F5:
            message = save_game(slot, game.to_save())
        elif event.key == pygame.K_F9:
            save, message = load_game(slot)
            if save is not None:
                game.apply_save(save)

The game object has two small methods that translate between the live game and the save: to_save() builds a SaveData from the current state (turning each coin's Vector2 into a [x, y] list), and apply_save() does the reverse. Everything else in the game stays exactly as it was.

An autosave is the same call on a timer. Like every timer in this course, it counts seconds with dt:

AUTOSAVE_EVERY = 60.0              # seconds

autosave_timer -= dt
if autosave_timer <= 0:
    autosave_timer = AUTOSAVE_EVERY
    message = save_game(0, game.to_save())       # slot 0 = the autosave slot

Try the whole pipeline in the browser. Change the game, save it in a slot, change it again, then load. Then damage a slot, or drop a version 1 save into slot 3, and load it to watch the validation and migration steps at work:

🏋️ Practice Exercise: Save Slots

Objective: give a small coin-collecting game three save slots that save with F5, load with F9, survive damaged files and upgrade old saves.

Time: about 50 minutes. Starter file: save_slots_starter.py (your instructor has it). The game, the SaveData dataclass, the two migration steps and the slot keys already work; F5 and F9 do nothing yet. Its numbered TODOs match the steps below.

  1. Run the starter. Collect a few coins and press 1, 2 and 3: the slot number changes. (≈ 3 min)
  2. Write save_game: make the folder, dump asdict(save) into the .tmp file, then os.replace it into place. (≈ 10 min)
  3. Write load_game: read the JSON inside try/except, build SaveData(**migrate(data)), and return (None, reason) for every failure. (≈ 10 min)
  4. Write validate so a value that is not a number, a negative score or play time, a position outside the window or a damaged coin list is rejected. (≈ 5 min)
  5. Handle F5 and F9 in the KEYDOWN branch. Save, move, load: the player should jump back. (≈ 5 min)
  6. Write migrate. Test it by writing {"version": 1, "x": 300, "y": 200, "score": 4} into saves/slot_3.json with a text editor and loading slot 3. (≈ 15 min)

You are done when:

  • F5 then F9 restores the position, score, play time and coins, even after you restart the program;
  • no .tmp file is left in the saves folder after saving;
  • a save you broke by hand shows "damaged" or "rejected" at the bottom of the window, and the game keeps running;
  • the version 1 save in slot 3 loads with the player at (300, 200), a score of 4 and a fresh set of coins.
💡 Hint

If loading says "unexpected keyword argument", the dictionary has a key SaveData doesn't know: check the migration renamed x to player_x with pop rather than copying it. If the migration loop never ends, make sure you read the new version from data["version"] after every step. json.JSONDecodeError is a kind of ValueError, so except (OSError, ValueError, TypeError) already catches a cut-off file.

✅ Example Solution

If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top, plus an extra and frame_budget() condition on the main loop. They let the instructor's checker run the program automatically; when you run it yourself they do nothing.

"""Save Slots: Intermediate Lesson 10 practice exercise (solution).

Collect coins, then save and load your progress in three slots.
Arrow keys move. 1, 2, 3 pick a slot. F5 saves, F9 loads.
Saves are JSON files in a "saves" folder next to this script. They are
written atomically, checked when loaded, and upgraded from older versions.
"""
import json
import os
import random
from dataclasses import asdict, dataclass, field
from pathlib import Path

import pygame


WIDTH, HEIGHT = 800, 600
PLAYER_SPEED = 260                 # pixels per second
PLAYER_SIZE = 32
COIN_COUNT = 5
SAVE_DIR = Path(__file__).parent / "saves"
SAVE_VERSION = 3                   # bump this whenever the save format changes


@dataclass
class SaveData:
    version: int = SAVE_VERSION
    player_x: float = WIDTH / 2
    player_y: float = HEIGHT / 2
    score: int = 0
    play_time: float = 0.0
    coins: list = field(default_factory=list)      # [[x, y], ...] still on the map


# --- migrations: one function per version step, each returns a NEW dict -------
def v1_to_v2(data):
    """Version 1 called the position x and y and had no play time."""
    data = dict(data)                              # copy: never change the caller's dict
    data["player_x"] = data.pop("x", WIDTH / 2)
    data["player_y"] = data.pop("y", HEIGHT / 2)
    data["play_time"] = 0.0
    data["version"] = 2
    return data


def v2_to_v3(data):
    """Version 3 remembers the coins; older saves get a fresh set."""
    data = dict(data)
    data["coins"] = []
    data["version"] = 3
    return data


MIGRATIONS = {1: v1_to_v2, 2: v2_to_v3}


def migrate(data):
    """Upgrade a loaded dict step by step to SAVE_VERSION, or raise ValueError."""
    version = data.get("version", 1)
    if not isinstance(version, int):
        raise ValueError(f"bad version {version!r}")
    if version > SAVE_VERSION:
        raise ValueError(f"save is from a newer game (version {version})")
    while version < SAVE_VERSION:
        step = MIGRATIONS.get(version)
        if step is None:
            raise ValueError(f"no migration from version {version}")
        data = step(data)
        version = data["version"]
    return data


def is_number(value):
    return isinstance(value, (int, float))


def validate(save):
    """Return None if the save is safe to use, or a short reason if it is not."""
    numbers = (save.score, save.player_x, save.player_y, save.play_time)
    if not all(is_number(n) for n in numbers):     # "ten" < 0 would raise TypeError
        return "a number field holds something else"
    if save.score < 0:
        return f"score {save.score} is negative"
    if not (0 <= save.player_x <= WIDTH and 0 <= save.player_y <= HEIGHT):
        return "player is outside the world"
    if save.play_time < 0:
        return "play time is negative"
    if not isinstance(save.coins, list) or not all(
            isinstance(c, list) and len(c) == 2 and all(is_number(n) for n in c)
            for c in save.coins):
        return "coin list is damaged"
    return None


def slot_path(slot, save_dir=SAVE_DIR):
    return Path(save_dir) / f"slot_{slot}.json"


def save_game(slot, save, save_dir=SAVE_DIR):
    """Write the save atomically: a temp file first, then one os.replace."""
    path = slot_path(slot, save_dir)
    tmp = path.with_name(path.name + ".tmp")
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(asdict(save), f, indent=2)
        os.replace(tmp, path)          # the old file stays intact until this line
    except OSError as error:
        return f"Save failed: {error}"
    return f"Saved slot {slot}"


def load_game(slot, save_dir=SAVE_DIR):
    """Return (SaveData, message) on success, or (None, reason). Never raises."""
    path = slot_path(slot, save_dir)
    if not path.exists():
        return None, f"Slot {slot} is empty"
    try:
        with open(path, encoding="utf-8") as f:
            data = json.load(f)
        if not isinstance(data, dict):
            raise ValueError("not a save file")
        save = SaveData(**migrate(data))
    except (OSError, ValueError, TypeError) as error:   # JSONDecodeError is a ValueError
        return None, f"Slot {slot} is damaged: {error}"
    problem = validate(save)
    if problem is not None:
        return None, f"Slot {slot} rejected: {problem}"
    return save, f"Loaded slot {slot}"


class Game:
    def __init__(self, seed=None):
        self.rng = random.Random(seed)
        self.pos = pygame.Vector2(WIDTH / 2, HEIGHT / 2)
        self.score = 0
        self.play_time = 0.0
        self.coins = [self.random_coin() for _ in range(COIN_COUNT)]

    def random_coin(self):
        return pygame.Vector2(self.rng.uniform(30, WIDTH - 30), self.rng.uniform(70, HEIGHT - 30))

    def update(self, direction, dt):
        self.play_time += dt
        self.pos += direction * PLAYER_SPEED * dt
        self.pos.x = max(0, min(WIDTH, self.pos.x))
        self.pos.y = max(0, min(HEIGHT, self.pos.y))
        for i, coin in enumerate(self.coins):
            if self.pos.distance_to(coin) < PLAYER_SIZE / 2 + 10:
                self.score += 1
                self.coins[i] = self.random_coin()

    def to_save(self):
        return SaveData(player_x=self.pos.x, player_y=self.pos.y, score=self.score,
                        play_time=self.play_time, coins=[[c.x, c.y] for c in self.coins])

    def apply_save(self, save):
        self.pos = pygame.Vector2(save.player_x, save.player_y)
        self.score = save.score
        self.play_time = save.play_time
        self.coins = [pygame.Vector2(x, y) for x, y in save.coins]
        while len(self.coins) < COIN_COUNT:        # older saves stored no coins
            self.coins.append(self.random_coin())


def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Save Slots")
    clock = pygame.time.Clock()
    font = pygame.font.Font(None, 30)
    game = Game()
    slot = 1
    message, message_time = "F5 save, F9 load, 1-3 pick a slot", 4.0
    history = []

    running = True
    while running:
        dt = min(clock.tick(60) / 1000, 0.05)
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN:              # one save per key press
                if event.key in (pygame.K_1, pygame.K_2, pygame.K_3):
                    slot = event.key - pygame.K_0
                    message = f"Slot {slot} selected"
                elif event.key == pygame.K_F5:
                    message = save_game(slot, game.to_save())
                elif event.key == pygame.K_F9:
                    save, message = load_game(slot)
                    if save is not None:
                        game.apply_save(save)
                else:
                    continue
                message_time = 3.0
                history.append(message)

        keys = pygame.key.get_pressed()
        direction = pygame.Vector2(keys[pygame.K_RIGHT] - keys[pygame.K_LEFT],
                                   keys[pygame.K_DOWN] - keys[pygame.K_UP])
        if direction.length_squared() > 0:
            direction = direction.normalize()
        game.update(direction, dt)
        message_time -= dt

        screen.fill((24, 28, 44))
        for coin in game.coins:
            pygame.draw.circle(screen, (255, 210, 60), coin, 10)
        player = pygame.FRect(0, 0, PLAYER_SIZE, PLAYER_SIZE)
        player.center = game.pos
        pygame.draw.rect(screen, (110, 200, 255), player, border_radius=6)
        hud = f"Slot {slot}   Score {game.score}   Time {game.play_time:5.1f} s"
        screen.blit(font.render(hud, True, (235, 235, 235)), (12, 10))
        if message_time > 0:
            screen.blit(font.render(message, True, (255, 230, 150)), (12, HEIGHT - 36))
        pygame.display.flip()

    pygame.quit()
    for line in history:
        print(line)


if __name__ == "__main__":
    main()

📓 Learning Journal

Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:

  • Key concepts you learned today
  • Techniques that clicked (and the ones that haven't, yet)
  • Questions or confusion to bring to the next session
  • Ideas to try in your own game
  • Progress and feelings: how did this lesson go for you?

✍️ This lesson's prompts:

  1. List every fact your favorite game must remember between sessions. Which ones could it rebuild instead of saving?
  2. Describe, step by step, what happens to a save file if the power fails halfway through json.dump, with and without the temp-file-then-os.replace pattern.
  3. Imagine your capstone's version 2 adds lives. Write the migration function you would need.

📝 Summary

You decided that a save holds facts, not pictures, and described those facts once as a @dataclass, so asdict() and SaveData(**data) carry them to and from JSON. Saves go to a temporary file and are swapped into place with os.replace, so a crash never leaves a half-written slot. Loading treats the file as untrusted: it catches unreadable files, upgrades old versions one step at a time, checks the values, and only then touches the live game. Finally, saving happens once per key press, and autosaves count seconds with dt.

🎓 Key Takeaways

  • Save the facts the game can't rebuild, never Surfaces, fonts or sounds.
  • A dataclass is the save format's single source of truth; JSON turns tuples into lists and keys into strings.
  • Write to a .tmp file, then os.replace it over the real one.
  • Loading returns a save or a reason; it never crashes and never half-applies a bad file.
  • Every save has a version; one migration per step, each copying and carrying all the data forward.
  • Trigger saves on KEYDOWN, not get_pressed(), and time autosaves in seconds.

🔭 Looking Ahead

Next, in Tile Maps, you build whole levels out of a grid of numbers, collide with them one axis at a time, and save and load those levels as JSON with the same care you learned here.

❓ Common Questions

Where should a real game put its save files?

During this course, a saves folder next to your script is easiest to find. A game you ship to other people should save in the player's own data folder instead, because the install folder may not be writable. pygame-ce can find one for you: pygame.system.get_pref_path("YourName", "YourGame") returns a folder you are allowed to write to, and creates it if needed.

Can players cheat by editing a JSON save?

Yes, and for a single-player game that is usually fine; plenty of players enjoy tinkering. Validation still matters, because it stops an edited or damaged file from crashing the game. Detecting tampering needs checksums or signatures, which belong to a later course.

Why does migrate treat a save with no version as version 1?

Because the very first version of many games forgot to store one. Treating "no version" as the oldest format means those saves still upgrade through the whole chain. From today on, every save you write carries version.

My loaded coins are lists, not Vector2s. Is that a bug?

No, that is JSON: it has no tuple or vector type, so to_save() writes each coin as [x, y], and apply_save() turns each pair back into a pygame.Vector2. Converting at the boundary keeps the save simple and the game code unchanged.

Should I save every frame so nothing is ever lost?

No. Writing a file takes real time and wears out storage for no benefit. Save when something meaningful happens (a checkpoint, a level cleared, the player pressing save or quitting) and, if you like, on an autosave timer measured in seconds or minutes.

🎯 Quick Quiz

Question 1: Why does save_game write to a .tmp file and then call os.replace?

Question 2: You save {"pos": (3, 4)} with json.dump and load it with json.load. What is data["pos"]?

Question 3: A game saves inside if keys[pygame.K_F5]: using get_pressed(). The player holds F5 for half a second at 60 FPS. About how many times is the file written?

Question 4: SAVE_VERSION is 3 and a player loads a version 1 save. What does migrate do?

Question 5: A save file contains a key "surprise" that SaveData doesn't have. What happens in load_game?

🌟 Going Further

  • Slot menu: add a load screen (a scene from Game States & Scenes, pushed on top of the game) that lists each slot's score and play time, read with load_game.
  • Backups: before os.replace, copy the old save to slot_1.json.bak. If a slot loads as damaged, offer to load the backup instead.
  • Version 4: add a best_score field. Write v3_to_v4, bump SAVE_VERSION, and check that a version 1 save still loads.
  • Read the docs: Python's json, dataclasses and os.replace.
  • Coming up in Game Dev II: Intermediate: Building Executables shows where a packaged game should keep its saves and settings.
  • Coming up in Game Dev III: Advanced: Robust Saves: Integrity, Versioning, Security adds checksums, binary formats and the dangers of loading untrusted data.