Code of the Day
IntermediatePolish and Persistence

Save, load, and config

Write game state to a JSON save file with pathlib, load it back on startup, and read a config.json for tunable game values.

Game DevIntermediate10 min read
Recommended first
By the end of this lesson you will be able to:
  • Write game state to a JSON save file using pathlib and json
  • Load a save file on startup with a sensible default when the file does not exist
  • Read a config.json for tunable values like volume and difficulty

The previous lesson identified what to save. This lesson shows the complete read/write cycle: writing state on quit, loading it on start, and separating it from configuration that designers might want to tweak without changing code.

This lesson does not use pygame at all — it is pure Python file I/O. You can run these examples directly with python save_demo.py without any additional libraries. pathlib and json are part of the standard library.

Save and load

import json
from pathlib import Path

# ── Paths ─────────────────────────────────────────────────────────────────────

SAVE_DIR  = Path.home() / ".mygame"
SAVE_FILE = SAVE_DIR / "save.json"

SAVE_DIR.mkdir(parents=True, exist_ok=True)   # create on first run


# ── Defaults ──────────────────────────────────────────────────────────────────

DEFAULT_SAVE = {
    "high_score":      0,
    "unlocked_levels": [1],
    "last_position":   {"x": 40, "y": 400},
}


# ── Save ──────────────────────────────────────────────────────────────────────

def save_game(score, unlocked, player_x, player_y):
    data = {
        "high_score":      score,
        "unlocked_levels": sorted(unlocked),
        "last_position":   {"x": player_x, "y": player_y},
    }
    SAVE_FILE.write_text(json.dumps(data, indent=2))
    print(f"Saved to {SAVE_FILE}")


# ── Load ──────────────────────────────────────────────────────────────────────

def load_game():
    if not SAVE_FILE.exists():
        print("No save file found — using defaults.")
        return dict(DEFAULT_SAVE)   # return a copy so defaults stay clean
    try:
        data = json.loads(SAVE_FILE.read_text())
        # Fill in any keys added in a newer game version
        for key, value in DEFAULT_SAVE.items():
            data.setdefault(key, value)
        return data
    except (json.JSONDecodeError, KeyError) as exc:
        print(f"Corrupt save file ({exc}) — using defaults.")
        return dict(DEFAULT_SAVE)


# ── Demo ──────────────────────────────────────────────────────────────────────

save_game(score=150, unlocked={1, 2, 3}, player_x=200, player_y=400)
state = load_game()
print("Loaded:", state)

Running this creates ~/.mygame/save.json with content like:

{
  "high_score": 150,
  "unlocked_levels": [1, 2, 3],
  "last_position": {"x": 200, "y": 400}
}

A second load_game() call reads it back. If the file does not exist (first run) or is corrupt, the defaults are returned without crashing.

Config file

Configuration values — volume, difficulty multiplier, key bindings, window size — do not belong in code. Putting them in a separate config.json means a level designer or player can tweak them without touching Python.

CONFIG_FILE = Path("config.json")

DEFAULT_CONFIG = {
    "volume":     0.7,
    "difficulty": "normal",   # "easy", "normal", "hard"
    "fullscreen": False,
}

def load_config():
    if not CONFIG_FILE.exists():
        return dict(DEFAULT_CONFIG)
    try:
        cfg = json.loads(CONFIG_FILE.read_text())
        for key, value in DEFAULT_CONFIG.items():
            cfg.setdefault(key, value)
        return cfg
    except (json.JSONDecodeError, KeyError):
        return dict(DEFAULT_CONFIG)

def save_config(cfg):
    CONFIG_FILE.write_text(json.dumps(cfg, indent=2))


# At game startup:
config = load_config()
VOLUME     = config["volume"]
DIFFICULTY = config["difficulty"]

# When the player changes volume in the settings screen:
config["volume"] = 0.5
save_config(config)

Using the values in a pygame game

Call load_game() and load_config() before your main loop starts. Call save_game() when the player quits (in the pygame.QUIT handler) and optionally on reaching each checkpoint.

# At startup
config = load_config()
state  = load_game()

# In the QUIT handler (after the main loop):
save_game(
    score     = current_score,
    unlocked  = unlocked_levels,
    player_x  = player.rect.x,
    player_y  = player.rect.y,
)

The setdefault pattern when loading ensures forward compatibility: if you add a new key to DEFAULT_SAVE in a future version, existing save files get the default for the new key rather than crashing with a KeyError.

Where to go next

Next: the Polish and Persistence lab — extend the tile platformer with jump and coin sound effects, looping background music, save-on-quit, and a settings scene.

Finished reading? Mark it complete to track your progress.

On this page