Save system

SaveManager saves game state. It walks a live scene tree, snapshots every Property declared with persist=True, and writes them to a file. Saves are atomic and keep two backups, so a crash mid-write never leaves the player with a corrupt save.

This is the one save pattern: a value that must survive a restart lives on a Property(persist=True, save_version=N), and SaveManager takes care of it.

Marking values as persistent

from simvx.core import Node, Property

class Player(Node):
    __save_version__ = 1

    hp = Property(100, range=(0, 9999), persist=True, save_version=1)
    max_hp = Property(100, range=(1, 9999), persist=True, save_version=1)
    level = Property(1, range=(1, 100), persist=True, save_version=1)
    gold = Property(0, range=(0, 999_999), persist=True, save_version=1)

    speed = Property(0.0)          # not persisted: rebuilt by gameplay after a load
  • persist=True opts a Property into the save. The default is False, so every saved value is marked explicitly.

  • save_version is optional; give every persisted Property one. It is the schema version of that one Property: bump it when the field’s meaning, units or range changes.

  • __save_version__ on the class is the version of the class’s whole save schema. Bump it when you add, remove or rename a persisted Property.

  • position, rotation and scale on Node2D and Node3D are persisted already.

Saving and loading

import os
from pathlib import Path
from simvx.core import SaveManager

def save_dir() -> Path:
    base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
    return base / "simvx" / "my_game" / "saves"

mgr = SaveManager(save_dir())        # without an argument: <cwd>/saves
path = mgr.save(root, "slot1")       # writes <save_dir>/slot1.sav
data = mgr.load("slot1")             # raises FileNotFoundError if absent
mgr.apply(root, data)                # restores the values onto a live tree

Save under the player’s own data directory, never beside the game: an installed game may be read-only. The directory is created on the first save.

load and apply are separate so a game can read the file when the player clicks Load and apply it once the new scene is in place. For example, the player’s position is applied after the level scene has been swapped in, not while the title screen is still mounted.

Atomic writes and backups

Every save() writes <slot>.sav.tmp, flushes and fsyncs it, rotates <slot>.sav.bak to <slot>.sav.bak2 and <slot>.sav to <slot>.sav.bak, then renames the temp file onto <slot>.sav. At every instant either the old or the new file is complete on disk.

SaveManager never falls back to a backup by itself, since that would hide data loss. To recover a corrupt save, copy <slot>.sav.bak (or .bak2) over it.

Errors

Nothing fails silently:

Failure

Exception

slot is empty

ValueError

the save file does not exist

FileNotFoundError

the file is not a save, or its format is missing or too new

RuntimeError

a saved node path no longer exists in the tree

RuntimeError

a saved class cannot be imported

ImportError

a stored save_version is newer than the class, or older with no migration

RuntimeError

a stored Property name is unknown to the class

RuntimeError

Catch these in the UI (“Slot 1 is corrupt; a backup is available”) rather than wrapping load in a blanket except Exception.

Migrating saves

When a class’s persisted shape changes, bump __save_version__ and add a __migrate_save__ classmethod. It receives the stored values, the stored version and the current version, and returns values that match the current Properties:

class Player(Node):
    __save_version__ = 3

    # v1 stored hp and max_hp; v2 added level; v3 renamed gold to coins.
    hp = Property(100, persist=True, save_version=1)
    max_hp = Property(100, persist=True, save_version=1)
    level = Property(1, persist=True, save_version=2)
    coins = Property(0, persist=True, save_version=3)

    @classmethod
    def __migrate_save__(cls, values: dict, from_v: int, to_v: int) -> dict:
        if from_v < 2 <= to_v:
            values.setdefault("level", 1)
        if from_v < 3 <= to_v and "gold" in values:
            values["coins"] = values.pop("gold")
        return values

A save from a newer build than the one loading it fails with a clear error; there is no forward migration.

Example: a pause menu and a title screen

examples/demos/dungeon_explorer/ saves its player and game state this way.

def _on_save_pressed(self) -> None:
    try:
        SaveManager(save_dir()).save(self.tree.root, "autosave")
    except OSError as exc:
        self.tree.events.publish(SaveFailed(reason=str(exc)))
        return
    self.tree.events.publish(SaveSucceeded())

def _on_continue_pressed(self) -> None:
    mgr = SaveManager(save_dir())
    try:
        data = mgr.load("autosave")
    except FileNotFoundError:
        self._show_message("No saved game found.")
        return
    except RuntimeError as exc:                # corrupt, or a version mismatch
        self._show_message(f"Save unreadable: {exc}")
        return
    self.tree.change_scene(GameplayScene())
    mgr.apply(self.tree.root, data)

SaveSucceeded and SaveFailed are the game’s own event classes; see Event Bus.

Choosing what to persist

Persist:

  • Progression: health, level, experience, currency, unlocked abilities, quest flags.

  • World state the player changed: chests opened, enemies defeated, doors unlocked.

  • Player choices that should survive a restart: difficulty, accessibility options.

Do not persist:

  • Derived state: cached spawn lists, the current animation frame. Recompute it in on_ready().

  • Frame-local physics: velocities, jump state.

  • References to other nodes: look them up again after apply().

Trust boundary

Saves are stored with pickle, which runs code on load. Only load saves the game wrote itself on the player’s own machine. Never pass a file from elsewhere (a download, a shared or modded save) to load(): a crafted .sav file can run any code with the game’s privileges. Cloud sync or save import needs integrity checking (an HMAC with a key the server holds, for example) before load(), which SaveManager does not provide.

Editor autosave is separate

The editor’s crash-recovery autosave (simvx.editor.autosave) snapshots the whole scene being edited, including unsaved nodes. It shares the atomic write, but it is a developer tool; game state for the player goes through persist=True and SaveManager.