SaveManager

Save, load and migrate persisted Properties

▶ Run in browser

Tags: basics save load persistence property

A player disc carries its position, score and colour as Property(persist=True) values. S snapshots the whole tree to slot1 through SaveManager and L loads it back and applies it, restoring exactly the persisted state. The player class is at __save_version__ = 2 and ships a __migrate_save__ hook that upgrades a version-1 payload (which stored the score under an old name) on apply.

Saves land in a fresh tempfile.mkdtemp() directory so this example never writes into the repository; a real game would point SaveManager at its own saves directory under the user’s data dir instead.

What it demonstrates

  • Property(persist=True): only flagged Properties enter the snapshot.

  • SaveManager(save_dir): save(root, "slot1"), load("slot1"), apply(root, data).

  • Restoring live state: position, score and colour snap back on load.

  • __save_version__ + __migrate_save__(values, from_v, to_v): upgrading an old payload on apply (exercised by the selftest).

Controls: Arrow keys - Move the player SPACE - Score points C - Cycle the player colour S - Save to slot1 L - Load slot1 and apply it ESC - Quit

Run: uv run python examples/features/basics/save_load.py Headless self-check: uv run python examples/features/basics/save_load.py –test

Source

  1"""SaveManager: Save, load and migrate persisted Properties
  2
  3A player disc carries its position, score and colour as ``Property(persist=True)``
  4values. S snapshots the whole tree to slot1 through ``SaveManager`` and L loads it
  5back and applies it, restoring exactly the persisted state. The player class is at
  6``__save_version__ = 2`` and ships a ``__migrate_save__`` hook that upgrades a
  7version-1 payload (which stored the score under an old name) on apply.
  8
  9Saves land in a fresh ``tempfile.mkdtemp()`` directory so this example never
 10writes into the repository; a real game would point ``SaveManager`` at its own
 11saves directory under the user's data dir instead.
 12
 13# /// simvx
 14# tags = ["basics", "save", "load", "persistence", "property"]
 15# web = { root = "SaveLoadDemo", width = 960, height = 540, responsive = true }
 16# ///
 17
 18## What it demonstrates
 19- `Property(persist=True)`: only flagged Properties enter the snapshot.
 20- `SaveManager(save_dir)`: `save(root, "slot1")`, `load("slot1")`, `apply(root, data)`.
 21- Restoring live state: position, score and colour snap back on load.
 22- `__save_version__` + `__migrate_save__(values, from_v, to_v)`: upgrading an
 23  old payload on apply (exercised by the selftest).
 24
 25Controls:
 26  Arrow keys - Move the player
 27  SPACE      - Score points
 28  C          - Cycle the player colour
 29  S          - Save to slot1
 30  L          - Load slot1 and apply it
 31  ESC        - Quit
 32
 33Run: uv run python examples/features/basics/save_load.py
 34Headless self-check: uv run python examples/features/basics/save_load.py --test
 35"""
 36
 37import tempfile
 38
 39from simvx.core import Colour, Input, Key, Node2D, Property, SaveManager, Vec2
 40from simvx.graphics import App
 41
 42WIDTH, HEIGHT = 960, 540
 43RADIUS = 24.0
 44SPEED = 320.0
 45PALETTE = [
 46    (0.4, 0.8, 1.0, 1.0),
 47    (1.0, 0.65, 0.25, 1.0),
 48    (0.55, 0.95, 0.45, 1.0),
 49    (0.95, 0.45, 0.75, 1.0),
 50]
 51
 52
 53class Player(Node2D):
 54    """The saveable entity: every gameplay value lives in a persisted Property."""
 55
 56    __save_version__ = 2
 57
 58    pos = Property(default_factory=lambda: Vec2(WIDTH / 2, HEIGHT / 2), persist=True, save_version=2)
 59    score = Property(0, persist=True, save_version=2)
 60    tint = Colour(PALETTE[0], persist=True)
 61
 62    @classmethod
 63    def __migrate_save__(cls, values, from_v, to_v):
 64        # Version 1 stored the score under "points"; version 2 renamed it and
 65        # added `tint`. SaveManager.apply calls this when a stored entry is
 66        # older than the class, and each Property absent from the payload
 67        # (here `tint`) simply keeps its current value.
 68        values = dict(values)
 69        if from_v == 1:
 70            values["score"] = values.pop("points", 0)
 71        return values
 72
 73    def on_draw(self, renderer):
 74        # Drawing the node's own Properties auto-dirties it when they change.
 75        renderer.draw_circle((self.pos.x, self.pos.y), RADIUS, colour=self.tint, filled=True)
 76        renderer.draw_circle((self.pos.x, self.pos.y), RADIUS, colour=(1, 1, 1, 0.6), filled=False)
 77
 78
 79class SaveLoadDemo(Node2D):
 80    # The HUD reads the player's Properties and a status string it does not
 81    # own, so the root opts into per-frame redraw.
 82    dynamic = True
 83
 84    input_actions = {
 85        "left": [Key.LEFT],
 86        "right": [Key.RIGHT],
 87        "up": [Key.UP],
 88        "down": [Key.DOWN],
 89        "score": [Key.SPACE],
 90        "colour": [Key.C],
 91        "save": [Key.S],
 92        "load": [Key.L],
 93        "quit": [Key.ESCAPE],
 94    }
 95
 96    def on_ready(self):
 97        # A throwaway directory keeps the example from littering the repo;
 98        # a real game would use its own saves dir under the user's data dir.
 99        self._mgr = SaveManager(tempfile.mkdtemp(prefix="simvx_save_load_"))
100        self._player = self.add_child(Player(name="Player"))
101        self._tint_i = 0
102        self._status = "no save yet (S to save)"
103
104    def on_update(self, dt: float):
105        p = self._player
106        move = Input.get_vector("left", "right", "up", "down")
107        if move.x or move.y:
108            target = p.pos + move * (SPEED * dt)
109            p.pos = Vec2(
110                min(max(target.x, RADIUS), WIDTH - RADIUS),
111                min(max(target.y, RADIUS), HEIGHT - RADIUS),
112            )
113
114        if Input.is_action_just_pressed("score"):
115            p.score += 10
116        if Input.is_action_just_pressed("colour"):
117            self._tint_i = (self._tint_i + 1) % len(PALETTE)
118            p.tint = PALETTE[self._tint_i]
119
120        if Input.is_action_just_pressed("save"):
121            path = self._mgr.save(self, "slot1")
122            self._status = f"saved -> {path}"
123        if Input.is_action_just_pressed("load"):
124            try:
125                data = self._mgr.load("slot1")
126            except FileNotFoundError:
127                self._status = "no slot1 save yet (press S first)"
128            else:
129                self._mgr.apply(self, data)
130                self._status = f"loaded slot1 (saved at {data['saved_at'][:19]})"
131
132        if Input.is_action_just_pressed("quit"):
133            self.app.quit()
134
135    def on_draw(self, renderer):
136        p = self._player
137        renderer.draw_text("SaveManager: persisted Properties", (10, 10), colour=(1, 1, 1), scale=2)
138        renderer.draw_text(f"Score: {p.score}", (10, 44), colour=(0.85, 0.85, 0.85))
139        renderer.draw_text(self._status, (10, 68), colour=(0.6, 0.85, 0.6))
140        renderer.draw_text(
141            "Arrows: move   SPACE: score   C: colour   S: save   L: load   ESC: quit",
142            (10, HEIGHT - 28),
143            colour=(0.6, 0.6, 0.6),
144        )
145
146
147def _selftest() -> bool:
148    """Headless: round-trip a save through SaveManager and migrate a v1 payload."""
149    import pickle
150    import shutil
151
152    ok = True
153
154    def check(label: str, passed: bool, detail: str) -> None:
155        nonlocal ok
156        ok = ok and passed
157        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
158
159    save_dir = tempfile.mkdtemp(prefix="simvx_save_load_test_")
160    mgr = SaveManager(save_dir)
161
162    root = Node2D(name="Root")
163    player = root.add_child(Player(name="Player"))
164
165    # Round trip: snapshot known values, trash them, load and apply.
166    player.pos = Vec2(123.0, 456.0)
167    player.score = 70
168    player.tint = PALETTE[2]
169    path = mgr.save(root, "slot1")
170    check("save wrote slot1.sav", path.exists(), str(path))
171
172    player.pos = Vec2(1.0, 1.0)
173    player.score = 0
174    player.tint = PALETTE[0]
175    mgr.apply(root, mgr.load("slot1"))
176    check(
177        "load + apply restored the persisted Properties",
178        player.pos == Vec2(123.0, 456.0) and player.score == 70 and player.tint == PALETTE[2],
179        f"pos={player.pos} score={player.score} tint={player.tint}",
180    )
181
182    # Migration: a hand-built version-1 envelope, where the score was still
183    # called "points". Player is at __save_version__ = 2, so apply must route
184    # the payload through Player.__migrate_save__ before setting anything.
185    legacy = {
186        "format_version": 1,
187        "engine_version": "selftest",
188        "saved_at": "2026-01-01T00:00:00+00:00",
189        "nodes": [
190            {
191                "path": "/Root/Player",
192                "class": f"{Player.__module__}.{Player.__qualname__}",
193                "save_version": 1,
194                "values": {"pos": Vec2(50.0, 60.0), "points": 999},
195            }
196        ],
197    }
198    (mgr.save_dir / "legacy.sav").write_bytes(pickle.dumps(legacy))
199
200    player.score = -1
201    mgr.apply(root, mgr.load("legacy"))
202    check(
203        "v1 payload migrated: 'points' became 'score'",
204        player.score == 999 and player.pos == Vec2(50.0, 60.0),
205        f"score={player.score} pos={player.pos}",
206    )
207    check(
208        "a Property the v1 payload never had keeps its value",
209        player.tint == PALETTE[2],
210        f"tint={player.tint}",
211    )
212
213    shutil.rmtree(save_dir, ignore_errors=True)
214    print("SELFTEST:", "PASS" if ok else "FAIL")
215    return ok
216
217
218if __name__ == "__main__":
219    import sys
220
221    if "--test" in sys.argv:
222        sys.exit(0 if _selftest() else 1)
223    App(title="Save / Load", width=WIDTH, height=HEIGHT).run(SaveLoadDemo())