gale.save

gale.save is a general-purpose save-game system: SaveManager persists whatever JSON-serializable dict a game gives it into named “slots” on disk, so the same class works for any game, no matter what it needs to remember.

Basic usage

from gale.save import SaveManager

manager = SaveManager()
manager.save("slot1", {"level": 3, "hp": 80})

data = manager.load("slot1")   # {"level": 3, "hp": 80}

By default, saves live under a saves/ directory next to the project’s own main.py (settings.SAVE_DIR, overridable in settings.py like every other gale setting).

Slots and a save-select screen

A slot is just a name – "slot1", "quicksave", "autosave", whatever a game’s own save UI calls it. Extra keyword arguments to save() are stored as metadata, readable without loading (and migrating) the full save, which is exactly what a save-select screen needs:

manager.save("slot1", {"level": 3}, chapter="The Forest", play_time=1523.4)

for info in manager.list_saves():   # most recently updated first
    print(info.slot, info.extra["chapter"], info.extra["play_time"])

manager.exists("slot1")     # True
manager.delete("slot1")

list_saves() silently skips a slot that fails to read (for instance, a save file corrupted by a crash mid-write from another program) rather than raising – a save-select screen should still show every other slot.

Autosaving

Not a built-in feature (a game decides its own cadence and trigger), but a one-liner on top of gale.timer.Every:

from gale.timer import Timer

Timer.every(60.0, lambda: manager.save("autosave", get_game_state()))

Evolving what a save contains

A shipped game’s save format inevitably needs to change (a new inventory system, a renamed field). SaveManager supports this with a schema version plus a migrations dict mapping “upgrade from this version” functions:

def add_mana(data):
    return {**data, "mana": 50}   # new field, gets a sane default

manager = SaveManager(
    version=2,
    migrations={1: add_mana},   # 1 -> 2
)

load() reads a save’s stored version and applies every migration needed to reach the manager’s current one, in order, before handing the data back – a save written by an older release of the game keeps working. Loading a save whose stored version is newer than the manager’s own (an old game binary opening a save from a newer one) raises SaveError, as does a stored version with no registered migration path forward.

Customizing the format

save_dir, version, and file_extension are also constructor parameters (each falls back to a gale.conf setting when omitted), and the wire format itself is pluggable, the same serializer/deserializer pattern gale.net.Server/Client already use – swap in a different one for a more compact binary format, or wrap the default to add compression/encryption:

import gzip

from gale.save import SaveManager, json_deserialize, json_serialize

def compressed_serialize(envelope):
    return gzip.compress(json_serialize(envelope))

def compressed_deserialize(data):
    return json_deserialize(gzip.decompress(data))

manager = SaveManager(
    serializer=compressed_serialize,
    deserializer=compressed_deserialize,
    file_extension="sav.gz",
)

Errors

Every failure SaveManager can hit while reading a save – a missing slot, a corrupted file, an unreachable schema version – is raised as SaveError, so a game only needs to catch one exception type around load():

from gale.save import SaveError

try:
    data = manager.load("slot1")
except SaveError as error:
    show_error_dialog(str(error))

Writes are atomic (a temporary file is renamed into place), so a crash or power loss mid-save can never leave a corrupted file behind – worst case, the previous save is still there untouched.