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=Trueopts a Property into the save. The default isFalse, so every saved value is marked explicitly.save_versionis 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,rotationandscaleonNode2DandNode3Dare 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 |
|---|---|
|
|
the save file does not exist |
|
the file is not a save, or its format is missing or too new |
|
a saved node path no longer exists in the tree |
|
a saved class cannot be imported |
|
a stored |
|
a stored Property name is unknown to the class |
|
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.
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.