Scenes

Scenes are Python source files. A scene file defines a Node subclass; loading imports the module and instantiates the class. There is no separate scene format — JSON and pickle exist for save-game data, not scenes.

A scene is just a Node (or Node subclass) intended to be the root of a tree. Nesting one scene inside another is a plain self.add_child(OtherScene()) in __init__. Custom behaviour is a Python class; instances configure via __init__ kwargs.

Loading

from simvx.core.scene_io import load_scene

scene = load_scene("scenes/level1.py")    # imports the module, instantiates the primary class
tree.set_root(scene)

The loader prefers, in order: a class whose name matches the file’s stem (level1.pyclass Level1), Root, then the only top-level Node subclass in the file.

Folder-as-scene is also supported: a directory with __init__.py (or <folder>/<folder>.py) is loaded as a package and the same naming heuristic applies.

Saving from source

The editor’s save path lives in simvx.core.scene_io:

from simvx.core.scene_io import SceneFile

# Greenfield: emit a fresh .py from a runtime tree.
SceneFile.from_runtime(root).save("scenes/level1.py")

# Preserve user formatting: parse the existing source, reconcile the tree
# against it, write back. Comments, blank lines, hand-written code, quote
# style and import ordering survive the round-trip.
sf = SceneFile.load("scenes/level1.py")
from simvx.editor.scene_diff import apply_runtime_diff
apply_runtime_diff(sf.scene_class(), root)
sf.save()

The editor’s state.save_scene() chooses between these paths automatically — greenfield for new scenes, preserve-mode for existing ones.

Reusable prefabs

A “prefab” is a Python class. Multiple instances are multiple constructor calls:

from .enemy import Enemy

self.add_child(Enemy(position=Vec2(10, 0)))
self.add_child(Enemy(position=Vec2(20, 0)))
self.add_child(Enemy(position=Vec2(30, 0)))

Variants are subclasses (class FastEnemy(Enemy):) or factory functions (def spawn_enemy(): return Enemy(...)).

Project-wide refactoring

simvx.core.scene_io.symbols exposes pure CST queries for class definition + use-site analysis, plus the in-place rename primitives those builds on:

from simvx.core.scene_io import (
    find_class_definitions, find_class_uses,
    rename_class_in_source, rename_module_in_imports,
)

tree = parse_source(open("src/player.py").read())
defs = find_class_definitions(tree)             # top-level class refs
uses = find_class_uses(tree, "Player")          # every reference, classified
rename_class_in_source(tree, "Player", "Hero")  # in-place mutation

Use-site kind covers import, import_alias, base_class, instantiation, isinstance_arg, annotation, bare_reference. Scope-aware: a local Player = MockPlayer shadow inside a function suppresses uses in that scope; aliased imports (Player as P) don’t propagate the rename to P calls.

The editor wraps these in simvx.editor.project_classes.rename_class(project_index, old, new, *, rename_file=False) — orchestrates the per-file rewrite + (optionally) renames the defining file and updates importers’ module paths via trailing-segment match. Atomic-ish: collects every new source in memory, then writes; rolls back on partial failure.

File ↔ folder refactoring

simvx.editor.refactor_extract.extract_to_folder(file_path, project_index) splits a multi-class .py into a sibling package — one file per class plus an __init__.py re-exporting each, so absolute imports keep resolving. Inverse: simvx.editor.refactor_inline.inline_to_file(folder_path, project_index, *, force=False). Both refuse cleanly on unsupported constructs (top-level free functions, side-effecting imports, conditional / control-flow blocks); force=True proceeds best-effort and returns an InlineResult.flagged audit trail of (file, line, reason) tuples for the editor’s review panel.

Identity-preserving rename on save

When the editor renames a node mid-session, the runtime canonical var name (derived from Node.name) no longer matches the source’s. Without a hint, apply_runtime_diff would emit remove + add at save time, losing the original source position. The editor builds an identity_hints: dict[Node, str] mapping at scene-load time keyed by Node identity:

apply_runtime_diff(scene_class, root, identity_hints=hints)

When a hint exists and the runtime canonical differs, the diff issues SceneClass.rename_child(...) in place. Backward compat: omitting identity_hints (or passing None) keeps the canonical-name-only behaviour for non-editor callers.

Scene Navigation

Swap the active root with SceneTree.change_scene() — autoloads persist, scene-local groups and unique-named nodes are rebuilt for the new tree:

def start_game(self):
    self.tree.change_scene(GameScene())

def game_over(self):
    self.tree.change_scene(GameOverScreen())

See Patterns for a title → gameplay → game-over flow.

API Reference

simvx.core.scene_ioload_scene, SceneFile, SceneClass, SceneModule, parse_source, emit_scene. simvx.core.scene_io.symbolsfind_class_definitions, find_class_uses, rename_class_in_source, rename_module_in_imports. simvx.editor.scene_diffapply_runtime_diff (with identity_hints). simvx.editor.project_classesProjectClassIndex, rename_class, RenameResult. simvx.editor.refactor_extractextract_to_folder, ExtractRefused. simvx.editor.refactor_inlineinline_to_file, FolderInlineRefused. simvx.core.scene_treeSceneTree.change_scene, add_autoload.