# 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: ```python 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 ```python from simvx.core import SaveManager mgr = SaveManager() # default: /saves/ path = mgr.save(root, "slot1") # writes /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: ```python 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 ``.sav.tmp``. 2. ``flush()`` + ``os.fsync()``. 3. If a previous ``.sav.bak`` exists, it is rotated to ``.sav.bak2``. 4. The current ``.sav`` (if any) is rotated to ``.sav.bak``. 5. ``.sav.tmp`` is renamed onto ``.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 ``.sav`` is somehow corrupt, copy ``.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: ```python @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. ```python 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: ```python # 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: ```python # 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: ```python 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": ```python 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.