Save System

SimVX persists game state through :class:simvx.core.SaveManager. The save manager walks a live scene tree, snapshots every Property flagged persist=True, and round-trips the data through pickle. Saves are atomic (write-temp + fsync + rename) and rotate two backups on every write so a mid-write crash never leaves the player with a corrupt file.

This is the one canonical save pattern. There are no parallel save APIs: no to_dict/from_dict on Node, no ad-hoc JSON dumps, no separate “checkpoint” subsystem. If a value needs to survive a process restart, it lives on a Property(persist=True, save_version=N) and the SaveManager takes care of it.

Editor autosave vs game saves

Two save paths exist; they solve different problems and must not be conflated:

Concern

API

Payload

When

Game state

:class:SaveManager

persist=True Property values only

Player saves the game.

Editor autosave

:mod:simvx.editor.autosave

Full scene-tree snapshot (crash recovery)

Editor writes a snapshot every N seconds.

Both share the same atomic-write-and-rotate primitive (:func:simvx.core.save_manager.pickle_atomic) so the on-disk safety guarantees are identical. They diverge in what is written: SaveManager pickles a small envelope of opted-in Property values; editor autosave pickles the full _serialize_node tree dump because it has to recover from a crash mid-edit, including unsaved nodes.

When in doubt: if a player will benefit from the data surviving a restart, it goes through persist=True and SaveManager. If a developer will benefit from recovering an unsaved scene after the editor crashes, that’s autosave.

Marking values as persistent

Add persist=True and save_version=N to any Property that should be saved:

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)

    # Transient — runtime-only, never saved.
    velocity_x = Property(0.0)
    velocity_y = Property(0.0)

Rules:

  • persist=True opts a Property into the save snapshot. The default is False – you must explicitly mark each saved value.

  • save_version is required when persist=True. It declares the schema version of that single Property. Bump it whenever the field’s meaning, units, or value space changes.

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

Any Property left at default persist=False is treated as transient and will be rebuilt from gameplay logic after load (think velocities, cached references, particle states).

Saving and loading

from simvx.core import SaveManager

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

The three-phase save / load / apply split is intentional: it lets the game distinguish “user clicked Load” (read the file) from “the new scene tree is ready to receive values” (apply onto a live tree). For example, the player’s spawn position must be applied after the level scene has been swapped in, not while the title screen is still mounted.

A custom save directory:

from pathlib import Path

mgr = SaveManager(Path.home() / ".local/share/my-game/saves")

The directory is created lazily on first save – there is no explicit “initialise saves directory” step.

Atomic write and backup rotation

Every save() call goes through

func:

simvx.core.save_manager.pickle_atomic. The contract:

  1. Pickle to <slot>.sav.tmp.

  2. flush() + os.fsync().

  3. If a previous <slot>.sav.bak exists, it is rotated to <slot>.sav.bak2.

  4. The current <slot>.sav (if any) is rotated to <slot>.sav.bak.

  5. <slot>.sav.tmp is renamed onto <slot>.sav.

At every instant either the old or the new file is fully on disk. The temp file is the only state that can be partial, and it never replaces the live file until fsync succeeds.

Manual recovery: if <slot>.sav is somehow corrupt, copy <slot>.sav.bak (or .bak2) over it. SaveManager does not fall back to the backups automatically – that would silently mask data loss.

Strict error handling

The save system follows the engine’s strict-by-default rule. None of the following are silently swallowed:

Failure

Exception

slot is empty

:class:ValueError

Save file does not exist

:class:FileNotFoundError

Pickle envelope is not a dict

:class:RuntimeError

Envelope format_version is missing or too new

:class:RuntimeError

Apply target node path no longer exists in the tree

:class:RuntimeError

Stored class cannot be re-imported

:class:ImportError

Stored save_version is newer than the class

:class:RuntimeError

Stored save_version is older and no migrator

:class:RuntimeError

Stored Property name is unknown to the live class

:class:RuntimeError

Catch these explicitly in the UI layer (“Slot 1 is corrupt; backup available”). Do not write blanket try/except Exception: pass blocks around mgr.load – the strict error is the feature.

Migrating saves across engine and game updates

When a class’s persisted shape changes, bump __save_version__ and provide a __migrate_save__ classmethod. The signature is:

@classmethod
def __migrate_save__(cls, values: dict, from_v: int, to_v: int) -> dict:
    ...

It receives the raw values dict from the save file, the stored version, and the current class version. Return a new dict that matches the current Property layout. Migrations chain by version: write straight-line code that walks from_v to to_v step by step.

class Player(Node):
    __save_version__ = 3

    # v1 stored hp + max_hp.
    # v2 added level.
    # v3 renamed gold -> 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

If the stored version is newer than the class supports, the load fails fast – there is no forward migration. Players running an older build than the save file see an explicit “save was made with a newer version” error rather than silent data loss.

Worked example: Dungeon Explorer

The Dungeon Explorer demo (games/dungeon_explorer/) ships a complete reference. Three Node classes drive the save:

# games/dungeon_explorer/nodes/player.py (excerpt)
class Player(CharacterBody2D):
    __save_version__ = 1

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

    saved_pos_x = Property(0.0, persist=True, save_version=1)
    saved_pos_y = Property(0.0, persist=True, save_version=1)

Position is mirrored onto saveable Properties (saved_pos_x / saved_pos_y) because :class:Node2D.position is a plain attribute, not a Property – the SaveManager only sees Property descriptors. Player code copies self.position.x into saved_pos_x before save and restores it after apply. (Lifting Node2D.position to a Property is tracked as engine TODO.)

The game-state class :class:GameManager aggregates non-Player state:

# games/dungeon_explorer/nodes/game_manager.py (excerpt)
class GameManager(Node):
    __save_version__ = 1

    play_time = Property(0.0, persist=True, save_version=1)
    total_kills = Property(0, persist=True, save_version=1)
    boss_defeated = Property(False, persist=True, save_version=1)
    quests_completed = Property((), persist=True, save_version=1)

Saving on the pause-menu “Save Game” button:

def _on_save_pressed(self) -> None:
    mgr = SaveManager(self.app.user_dir / "saves")
    try:
        mgr.save(self.tree.root, "autosave")
    except OSError as exc:
        self.tree.events.emit(SaveFailed(reason=str(exc)))
        return
    self.tree.events.emit(SaveSucceeded())

Loading on title-screen “Continue”:

def _on_continue_pressed(self) -> None:
    mgr = SaveManager(self.app.user_dir / "saves")
    try:
        data = mgr.load("autosave")
    except FileNotFoundError:
        self._show_message("No saved game found.")
        return
    except RuntimeError as exc:                # corrupt or version-mismatch
        self._show_message(f"Save unreadable: {exc}")
        return

    self.tree.change_scene(GameplayScene())
    mgr.apply(self.tree.root, data)            # apply onto the live new tree

See :doc:event_bus for the typed SaveSucceeded / SaveFailed event pattern – it pairs naturally with the save flow.

Choosing what to persist

Use persist=True for:

  • Player progression: hp, level, xp, currency, unlocked abilities, quest flags.

  • World state the player observably affected: chests opened, NPCs killed, doors unlocked, dialogue trees seen.

  • Player choices: difficulty, control bindings, accessibility toggles – if they should round-trip across runs.

Leave at persist=False (the default) for:

  • Derived state: cached enemy spawn lists, current animation frame, particle emitter age.

  • Frame-local physics: velocities, accelerations, jump state.

  • References to other live nodes: pickling a live Node would either drag the whole tree into the save or detach the reference on load. Restore these in ready() after apply() runs.

If a value is computable from other persisted state, recompute it in ready() instead of storing it.