26. Hot Reload

# 26 - Hot Reload Hot reload lets you edit a file while the game is running and see the change within about a second, without restarting. It watches **scene files** and **module files** you explicitly opt in with `watch_scene` and `watch_module`. Use it while iterating on balance numbers, tuning a stats file, or rebuilding a level layout. Do **not** ship it enabled — it's a development tool. ## Enabling it Constructor: ```python engine = LunaEngine("My Game", 1024, 720, hot_reload=True) ``` At runtime, via the LiveInspector: **Settings** tab → **Hot Reload** switch. Flipping it on starts the poll; flipping it off stops it. The engine polls every 0.5 seconds. Polling is driven by the existing `Timer` system — no threads, no background processes. A poll is one `os.path.getmtime()` call per watched file, which the OS serves from its metadata cache. Cost is effectively zero while nothing is changing. ## Watching a scene ```python engine.watch_scene("game") # by registered name engine.watch_scene(GameScene) # by class ``` `watch_scene` accepts a registered scene name (`str`) or the scene subclass itself. Both resolve to the scene's source file. When that file changes: 1. Old scene: `on_exit()` 2. New instance: `new_class(engine)` — full `__init__` runs again 3. New scene: `on_enter()` 4. Registered callbacks fire **No state is preserved.** Score, position, which tab is open, focused widget — everything resets. This is intentional. State preservation is a rabbit hole (renamed fields, changed types, stale references); a fresh rebuild is honest about what happened. If you need state to survive, expose a hook on the scene itself: ```python class DialogueScene(Scene): def on_hot_reload_snapshot(self) -> dict: return {"node_id": self.current_node.id, "seen": self.seen_nodes} def on_hot_reload_restore(self, data: dict): self.current_node = self.nodes[data["node_id"]] self.seen_nodes = data["seen"] ``` That pattern is optional and lives with the scene, not the engine. ## Watching a module ```python import stats engine.watch_module(stats) engine.watch_module("balance") # by import name engine.watch_module("src/balance.py") # by file path (str) engine.watch_module(Path("src/balance.py")) # Path engine.watch_module(atlas_item) # path-backed AtlasItem ``` All six forms resolve to a source file. If the module isn't imported yet, use the path form — the engine imports it on first reload. When the file changes, `importlib.reload(module)` runs. The module object is mutated in place, so any code that holds a reference to it sees the new values. **Packed `.res` AtlasItems raise `ValueError`.** Files inside a bundle aren't individual on disk, so there's nothing to reload. ## Callbacks ```python def refresh(self, module): self.damage = module.BASE_DAMAGE self.crit = module.CRIT_MULT engine.on_module_reload("stats", refresh) def on_scene_rebuilt(self, new_scene): self.engine.show_notification("Scene reloaded", "info", duration=1.0) engine.on_scene_reload("game", on_scene_rebuilt) ``` The module callback receives the reloaded module object. The scene callback receives the new scene instance. For a module that's just constants, you don't need a callback at all — read the values directly: ```python import stats # Read through the module, not through a local copy. damage = stats.BASE_DAMAGE ``` ## The `from` import trap This will not hot-reload: ```python from stats import BASE_DAMAGE # snapshot, never updates ``` This will: ```python import stats damage = stats.BASE_DAMAGE # reads the current module attribute ``` `from X import Y` copies the value into your namespace at import time. Reloading `stats` replaces `stats.BASE_DAMAGE`, but the local `BASE_DAMAGE` still points at the old value. Use `import module; module.NAME` for anything you want to reload. If a scene or UI class caches a value at `__init__`, add a module-reload callback to refresh the cached copy. There's no way for the engine to know which attributes are cached. ## Watched modules don't cascade If `stats.py` imports `curves.py` and you edit `curves.py`, reloading `stats` does **not** re-execute `curves`. Python caches imported modules. You have two options: 1. Watch `curves` too, in dependency order. 2. Live with it — most files you edit don't have runtime imports you're also editing. For typical balance iterations (edit `stats.py`, save, see the change) this never comes up. ## Syntax errors don't kill the game If you save a file mid-edit and it has a syntax error, `importlib.reload` raises `SyntaxError`. The engine catches it, logs it to the LiveInspector console pane at `ERROR` level, and **keeps the old version running**. Your game doesn't crash. ```python # What you see in the console: # [12:34:56] File "stats.py", line 4 # [12:34:56] BASE_DAMAGE = = 10 # [12:34:56] ^ # [12:34:56] SyntaxError: invalid syntax ``` Fix the file, save again, and the next poll reloads it cleanly. You'll lose a second, not the run. ## The debounce An editor often writes a file in multiple steps: truncate, write header, write body, close. Without protection, the poll can catch a half-written file. The engine requires the file's mtime to be stable for **one full poll cycle** (~0.5s) before reloading. So a save that finishes in 50ms reloads on the next poll. A save that takes 2 seconds reloads 0.5s after it stops changing. You can watch the console pane if you want to see reloads land. ## A working example `examples/hot_reload_demo.py` shows the full pattern. **`stats.py`:** ```python PLAYER_SPEED = 200 MAX_HEALTH = 100 STARTING_GOLD = 50 ``` **The scene:** ```python import stats class GameScene(Scene): def __init__(self, engine): super().__init__(engine) self._apply_stats() self.label = TextLabel(20, 20, "", 20) self.add_ui_element(self.label) def _apply_stats(self): self.speed = stats.PLAYER_SPEED self.max_hp = stats.MAX_HEALTH self.gold = stats.STARTING_GOLD def update(self, dt): self.label.set_text( f"speed={self.speed} hp={self.max_hp} gold={self.gold}" ) class HotReloadDemo(Scene): def __init__(self, engine): super().__init__(engine) engine.hot_reload = True engine.watch_module("stats") engine.on_module_reload("stats", lambda m: self.game._apply_stats()) engine.watch_scene("game") ``` Change `STARTING_GOLD = 50` to `STARTING_GOLD = 77`, save, and the label updates within a second. Change something in `hot_reload_scenes.py`, save, and the whole scene rebuilds. ## What to watch for - **Editors that write via rename** can confuse the mtime check on some filesystems. If a save doesn't trigger a reload, try saving twice. - **`__pycache__`** doesn't matter — `importlib.reload` bypasses the cache for watched modules. - **Callbacks are one-shot per reload.** If you add the same callback twice, it fires twice. Remove callbacks via the manager if you need to. - **The whole scene rebuilds.** Scene-local data structures, cached textures, tweens in flight — all gone. That's the point. ## When not to use it - **During a playtest you don't want to interrupt.** Turn it off. Reloading mid-boss-fight is worse than restarting. - **On target hardware.** Polling is cheap but non-zero, and the debug hooks add surface area. Ship with `hot_reload=False`. - **For code you didn't opt in.** Only watched files reload. Everything else is cached and unaffected. The design is deliberate: **you choose what reloads, when.** The engine doesn't guess.