SaveManager¶
Save, load and migrate persisted Properties
▶ Run in browserTags: 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())