Scenes

Scenes are Node subclasses defined in .py source files, nothing more. Loading a scene is just importing the module and instantiating the class. There is no .tscn, no .scene, no JSON, no codegen step, no intermediate serialisation. Save files (player saves, replays) optionally use pickle via Save System; scene persistence is always plain Python source.

A scene intended to be a root is just a Node subclass. Nesting one inside another is 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_io: load_scene, SceneFile, SceneClass, SceneModule, parse_source, emit_scene. simvx.core.scene_io.symbols: find_class_definitions, find_class_uses, rename_class_in_source, rename_module_in_imports. simvx.editor.scene_diff: apply_runtime_diff (with identity_hints). simvx.editor.project_classes: ProjectClassIndex, rename_class, RenameResult. simvx.editor.refactor_extract: extract_to_folder, ExtractRefused. simvx.editor.refactor_inline: inline_to_file, FolderInlineRefused. simvx.core.scene_tree: SceneTree.change_scene, add_autoload.