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: |
|
Player saves the game. |
Editor autosave |
:mod: |
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=Trueopts a Property into the save snapshot. The default isFalse– you must explicitly mark each saved value.save_versionis required whenpersist=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:
Pickle to
<slot>.sav.tmp.flush()+os.fsync().If a previous
<slot>.sav.bakexists, it is rotated to<slot>.sav.bak2.The current
<slot>.sav(if any) is rotated to<slot>.sav.bak.<slot>.sav.tmpis 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 |
|---|---|
|
:class: |
Save file does not exist |
:class: |
Pickle envelope is not a dict |
:class: |
Envelope |
:class: |
Apply target node path no longer exists in the tree |
:class: |
Stored class cannot be re-imported |
:class: |
Stored |
:class: |
Stored |
:class: |
Stored Property name is unknown to the live class |
:class: |
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()afterapply()runs.
If a value is computable from other persisted state, recompute it in
ready() instead of storing it.