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.

The save is worked out before it is written

Not every scene can be written back the way its author left it. A file that builds its children in a loop, in a conditional or in a helper method has no statement the save can match them against, so they are written out again beside the ones the file already builds; removing a child takes the author’s own statements that stood on it; and a value with no source form never reaches the file at all. All of that is settled on a parsed copy in memory, before anything is written, so the editor asks first rather than reporting afterwards over a file it has already overwritten:

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:                # what the save would also do
        print(entry)

Every entry carries a category, and it decides who gets interrupted. An entry is destructive when the save would change your own __init__ beyond carrying the scene across: statements swept out with a child you deleted, an attribute those statements left unbound, a child written a second time because the file builds it where the save cannot see it. It is informational when your line stays exactly as you wrote it and the value, the order or the class swap the scene holds simply does not reach the file.

plan = state.plan_save("scenes/level1.py")
plan.clean          # nothing to report at all
plan.destructive    # at least one entry would rewrite your __init__
for entry in plan.report:
    print(entry, entry.category)   # an entry prints as its own message

state.save_scene() is the two together: a plan with a destructive entry is shown to you first, so the file on disk stays as it is until you accept, and everything else is written straight away. An informational entry is written-and-warned rather than asked about – nothing of yours is lost, so the message goes to the console and the tab stays marked modified, which is the answer Godot, Unity and Unreal all give a value their serialiser cannot carry. The prompt lists both classes when it opens, under headings that say which is which. A caller with nobody to ask – a script, a headless save – commits either way and the report goes to the log.

A plan describes one copy of a file and one scene tree, and both can move while you are reading the report: the report names lines to go and fix, and the editor reloads a scene whose file changes underneath it. commit_save will not write such a plan – it says why in the log and answers False, rather than putting text parsed before your edit over the top of it. Work the save out again to save it; save_scene does that for you and asks again over the fresh report, unless the tab it planned for is no longer the one being edited – re-planning then would write whatever scene is now in front of you into the other tab’s file, so it writes nothing and logs that instead.

How wide a written line gets

A construction that fits on one line is written on one line. One that does not is broken up: its arguments go on a single indented line of their own if they fit there, and otherwise one keyword argument per line with a trailing comma and the closing parenthesis below them. A lone argument is the exception: it is everything inside the brackets, so there is nothing for a comma to hold it apart from, and it takes none. A list or dict that has to break at all breaks one item per line.

Every other line the emitter writes answers to the same rule. An over-long add_child puts its child on a line of its own; a class header does the same with its base; a from simvx.core import ... line goes into brackets, one name per line each with a comma, once the names outgrow the width. That is the rule black applies at 120 columns, which is this project’s width, so a statement the editor writes is laid out the way the author would have laid it out. The width is counted in columns rather than characters, so a label of Chinese or a line of emoji, each character of which takes two columns on the screen, breaks where a formatter breaks it rather than well past the limit.

How a value is written

A number keeps every bit of itself: what goes into the file is the shortest decimal string that reads back as exactly the value the scene held, at the precision it is held at. A property carrying 1 / 3 comes out as 0.3333333333333333, and a Vec2 component – a float32 – comes out as 0.33333334, which is the shortest string that round-trips through float32 rather than the eleven digits of its float64 expansion. A float that happens to be integral keeps the plain 2.0 an author would have written.

Whether a value is written at all is a coarser question. A property is left off the call when it holds the default it was declared with, and for a node’s position, rotation and scale “the default” is read with a tolerance of 1e-9: a position of Vec2(5e-10, 0) counts as the origin and reaches disk as no position at all, so it loads back as an exact zero. Everything else is compared exactly, so a speed of 5e-10 against a default of 0.0 is written out in full.

The spelling is a formatter’s own. Strings are double-quoted, except where that would mean escaping more than the single-quoted form does, so 'say "hi"' stays as it is. An exponent loses its + and keeps its -: 1e16, 1e-05. Together with the layout above, that makes a file this emitter writes one that black at 120 columns has nothing to say about, and a line preserve mode rewrites is spelled the same way, so a save leaves no formatting diff behind for someone else to commit.

Preserve mode applies the same rule to the statements it rewrites, and to no others – the import line a newly-added node’s type goes onto among them. A save with no edits in it writes the file back byte for byte, with one exception: a property sitting at its default and written out as a call or a tuple spelled some other way than a save spells it – position=Vec2(0, 0) against the Vec2(0.0, 0.0) a save writes – is read as a value you cleared in the editor, and the kwarg goes. A collision shape is read as geometry rather than as text, so every honest spelling of one survives, and one whose geometry sits behind a name – SphereShape3D(radius=RADIUS) – is left exactly as it stands, with a resize you made to that collider reported rather than written over it. Changing a collider’s kind is carried across wherever both the line and the scene spell their shape out: SphereShape3D(radius=2.0) becomes BoxShape3D(half_extents=Vec3(...)) and the import follows, while the line whose radius is behind a name keeps its kind too, since rewriting it would throw away the only record of a geometry a save cannot read. A construction the author broke up by hand, with a trailing comma after the last argument, stays broken up however short it gets, as black also leaves it. A statement carrying a comment keeps the shape it has, since a comment belongs to the line it was written on and no layout may move it off.

What a scene file cannot carry

A scene is source, so every value in it has to be writable as source. Paths, numbers, vectors, enums, Resource, and a Texture over a file all are, and so are the primitive collision shapes of both dimensions – SphereShape3D, BoxShape3D, CapsuleShape3D, CylinderShape3D, CircleShape2D, RectangleShape2D, CapsuleShape2D and SegmentShape2D – each written as the constructor call that rebuilds its geometry, so a scene with colliders in it round-trips through the editor with the sizes it was authored at. Pixels held in memory are not: an RGBA array, encoded image bytes, a Texture built from either, a node reference, a callable. Neither is a shape carrying vertex data or a point cloud (ConvexHullShape3D, ConcaveMeshShape3D, ConvexPolygonShape2D, ConcavePolygonShape2D): build those in on_ready() from the mesh they belong to. emit_scene (and SceneFile.from_runtime) raise UnemittableValueError rather than write a file that loads back as a different scene:

from simvx.core.scene_io import SceneFile, UnemittableValueError

refusals: list[str] = []
SceneFile.from_runtime(root, report=refusals).save("scenes/level1.py")
if refusals:
    ...  # written without those values; each message names the node and property

Passing report chooses the other policy, which is what the editor does: it puts the refusals in front of you first (above), then writes what it can, shows each refusal in the console, and leaves the tab marked modified. Either way the fix is the same – give the texture a file, or build the value in on_ready(), which is where code goes.

Refusal covers declared Property values. A plain attribute a node assigns in its own __init__MeshInstance3D.material is one – is not part of what a scene emits: a greenfield save drops it silently, and preserve mode keeps whatever the file already said.

A Texture two nodes share is written once into a local and referenced by name, so the reloaded scene shares it too and one update() still changes both. Preserve mode leaves a shared local the author wrote – and any other reference spelling, art.ICON or ART['icon'] – exactly as it stands; it does not introduce one into a file that never had it.

It also does not overwrite one, and the same goes for any call the emitter would not itself have written: rotation=math.radians(45) is a spelling this layer cannot read either. So a value you change in the editor on a slot written any of those ways does not reach the file. Each such value is reported at save and the tab stays modified; a slot whose value the file already yields says nothing, whatever it is spelled as.

The message stops when the file starts yielding what the scene holds. Editing the expression does that, and so does leaving the line alone and repointing what it names – change Art.ICON itself and every texture=Art.ICON in the project is answered at once. To know which, the editor re-reads the file it is about to write, which for a scene means running it: scenes are code, and there is no way to learn what Art.ICON evaluates to without executing the module. That read happens only on a save that actually declined something. If you rewrite the expression to a spelling the editor still cannot read, it takes your edit at its word rather than nagging about a slot you have dealt with.

A value that has no source form at all is reported the same way on this path, since keeping the author’s line and saying nothing would be the silent loss a save from scratch already refuses to make: swap a file-backed texture for one built from pixels and the save tells you, keeps the line, and leaves the tab modified until the scene stops holding something no file can carry.

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(): singletons 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_singleton.