Scenes

Scenes are Node subclasses defined in .py source files. Loading a scene imports the module and instantiates the class; there is no other scene format and no code generation. Player saves are separate; see Save system.

A scene intended to be a root is 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.py → class 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() uses greenfield mode for a new scene and preserve mode for an existing one.

What the editor promises

  • Any valid Python opens. A scene is read with CPython’s own parser, so match, except*, type aliases, t-strings and any newer syntax are fine. A file that does not parse is refused with the interpreter’s own message.

  • An edit rewrites only the statements it concerns. Renaming a node touches the lines that build and name it; changing a property touches its call; adding a child adds its statements and import. Helper methods, constants, comments and on_ready() come back as you left them.

  • A save with no edits writes the file back unchanged, except that a property written at its default in another spelling (Vec2(0, 0) for Vec2(0.0, 0.0)) is dropped, and a node that sizes itself on construction (a Label fitting its text) may gain size_x and size_y once.

  • Written code is formatted as ruff format at 120 columns would format it, and numbers are written as the shortest decimal that reads back exactly (a float32 Vec2 component as 0.33333334).

The save asks before it rewrites your code

Some scenes cannot be written back exactly as authored: children built in a loop, a conditional or a helper method give the save no statement to match, and deleting a child removes the statements that used it. The editor works the save out in memory first:

plan = state.plan_save("scenes/level1.py")   # writes nothing
if plan.clean:                               # nothing beyond your edits
    state.commit_save(plan)
else:
    for entry in plan.report:
        print(entry, entry.category)

A destructive entry (your __init__ would change beyond your edit) is shown to you before anything is written. An informational one (a value the file cannot carry; your line stays as it was) is written, reported in the console, and the tab stays marked modified. A plan whose file or scene changed after it was made is not committed; plan again.

What a scene file cannot carry

Every value in a scene must be writable as source. Paths, numbers, vectors, enums, Resource, a Texture over a file and the primitive collision shapes (SphereShape3D, BoxShape3D, CapsuleShape3D, CylinderShape3D, CircleShape2D, RectangleShape2D, CapsuleShape2D, SegmentShape2D) all are. Pixels in memory, a texture built from them, a node reference, a callable, and shapes made of vertex data (ConvexHullShape3D, ConcaveMeshShape3D, ConvexPolygonShape2D, ConcavePolygonShape2D) are not: build those in on_ready().

SceneFile.from_runtime raises UnemittableValueError for such a value rather than write a file that loads back as a different scene. Pass report= to write what it can and collect the rest:

from simvx.core.scene_io import SceneFile

refusals: list[str] = []
SceneFile.from_runtime(root, report=refusals).save("scenes/level1.py")
for message in refusals:    # each names the node and property
    print(message)

Only declared Property values are written; a plain attribute a node sets in its own __init__ is not part of the scene.

In preserve mode, a value the author wrote as an expression the editor cannot evaluate (rotation=math.radians(45), texture=Art.ICON) is left exactly as written. An editor change to it is reported rather than written, until the file yields the value the scene holds. A Texture shared by two nodes is written once and referenced by name, so the reloaded scene shares it too.

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 answers where a class is defined and where it is used, and rewrites both. Every function takes the source text of one module; the renames return the rewritten text, splicing the identifiers that move and leaving every other byte as the author wrote it:

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

source = Path("src/player.py").read_text()
defs = find_class_definitions(source)                     # top-level class refs
uses = find_class_uses(source, "Player")                  # every reference, classified
renamed = rename_class_in_source(source, "Player", "Hero")

A use’s kind is import, import_alias, base_class, instantiation, isinstance_arg, annotation or bare_reference. The search is scope-aware: a local Player = MockPlayer shadows the class in its function, and an aliased import (Player as P) keeps its alias.

The editor wraps these in simvx.editor.project_classes.rename_class(project_index, old, new, *, rename_file=False), which rewrites every file in the project, optionally renames the defining file and updates importers, and rolls back if a write fails.

File and folder refactoring

simvx.editor.refactor_extract.extract_to_folder(file_path, project_index) splits a multi-class .py into a package: one file per class plus an __init__.py re-exporting each, so imports keep working. simvx.editor.refactor_inline.inline_to_file(folder_path, project_index, *, force=False) is the inverse. Both refuse files with top-level functions, side-effecting imports or conditional blocks; force=True proceeds and lists each suspect construct in InlineResult.flagged. Custom classes in the editor covers the same actions in the editor.

Renaming a node keeps its place

When a node is renamed in the editor, apply_runtime_diff(scene_class, root, identity_hints=hints) renames the statement in place rather than removing and re-adding it, using a dict[Node, str] of the names each node had when the scene was loaded.

Scene navigation

Swap the active root with SceneTree.change_scene(). The old scene is destroyed: each of its nodes runs on_exit_tree(), its signal connections and event bus subscriptions end, and none of its nodes can be mounted again. Returning to a menu means building a new one, and change_scene() refuses a scene it already replaced before touching the current one. Carry state across as values passed to the new root. Singletons persist; groups and unique names are rebuilt for the new scene:

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

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

See Patterns for a title, gameplay and game-over flow.

API reference

simvx.core.scene_io: load_scene, SceneFile, SceneClass, SceneModule, 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_singleton.