Node system

Nodes are the building blocks of every SimVX game. They form a tree hierarchy managed by the SceneTree.

Lifecycle

Every node goes through these stages:

  1. Construction: __init__() sets up properties

  2. Enter tree: Node is added via add_child(); on_enter_tree() fires

  3. Ready: on_ready() is called once, after all children are ready (bottom-up)

  4. Process: on_update(dt) runs every frame

  5. Physics process: on_fixed_update(dt) runs at fixed intervals (default 60 Hz)

  6. Input: on_input(event), delivered in reverse tree order until a handler claims the event

  7. Draw: on_draw(renderer) emits 2D draw commands. The 2D renderer is retained: it re-runs on_draw only when the node is dirty. A Property or transform write auto-dirties; for per-frame animation set dynamic = True, and for a discrete non-Property change call queue_redraw() at the mutation site (see 2D drawing).

  8. Exit tree: destroy() marks the node; at the frame boundary on_exit_tree() fires and the subtree is carried out, exactly once per node

Multiple handlers for the same hook can be registered with decorators (@on_update, @on_input("jump"), @on_input(Key.CTRL + Key.ESCAPE)). Decorated handlers run in declaration order. An @on_input handler claims the event, stopping every later handler, by returning True or by carrying consume=True and not returning False.

import math

class Enemy(CharacterBody3D):
    health = Property(100, range=(0, 200))

    def on_update(self, dt):
        self.rotate((0, 1, 0), math.radians(45) * dt)  # 45°/sec: rotate() takes radians

    def on_fixed_update(self, dt):
        self.move_and_slide(dt)

Properties

Property declares editor-visible, serialisable values with optional validation:

from enum import StrEnum

from simvx.core import Node3D, Property, Signal

class Armour(StrEnum):
    LIGHT = "light"
    MEDIUM = "medium"
    HEAVY = "heavy"

class Tank(Node3D):
    died = Signal()
    speed = Property(5.0, range=(0, 20), hint="Movement speed")
    armour = Property(Armour.HEAVY)
    active = Property(True)
    health = Property(100, on_change="_on_health_changed", persist=True)

    def _on_health_changed(self):
        if self.health <= 0:
            self.died.emit()

Constructor arguments:

Arg

Purpose

default / default_factory

initial value (use default_factory for mutable defaults)

range=(lo, hi)

numeric clamp + inspector field bounds

clamp=False

make range a soft hint: assignments pass through unclamped and the inspector field stretches to show an out-of-range value. Use it for magnitudes whose range is a suggestion (light intensity, particle speed); leave it on where the range is validation

enum=[...]

allowed values + inspector dropdown. Prefer a StrEnum default, which implies both and accepts the member or its string

hint=

tooltip text shown in the editor inspector

on_change="method"

name of a zero-arg bound method invoked after each value change. Hooks fired while __init__ is running are deferred until init returns and deduplicated by (property, hook), so the hook always sees a fully constructed object.

link=True

child’s resolved value is offset from the parent’s same-named property (cumulative scale, accumulated tint, etc.)

propagate=True

bool/enum properties inherit disabling values from parents (e.g. visible, update_mode); implies link=True

persist=True

included in SaveManager snapshots

save_version=N

schema version recorded with the persisted value

Query all properties on a class with get_properties():

Tank.get_properties()  # {"speed": Property(...), "armour": Property(...), ...}

Hierarchy

Nodes have a single parent and any number of children:

root = Node(name="Root")
player = Node3D(name="Player")
camera = Camera3D(name="Camera", position=(0, 5, 10))

root.add_child(player)
player.add_child(camera)

# Access children by name or index
root.children["Player"]       # by name
root.children[0]              # by index

# Path-based access on the node itself
root["Player/Camera"]         # slash-separated path
camera.node_at("../Player")  # relative path (sibling)
camera.node_at("/Root")      # absolute path

# Node properties
camera.parent                 # direct parent
camera.path                   # "/Root/Player/Camera"
camera.tree                   # the SceneTree

Reordering children

The Children collection isn’t a Python list, and it is the only place a child list may be reordered: it is what tells the tree its shape changed, which input delivery order and every render cache depend on. There is no append, remove or insert, and its backing list is private on purpose. Use the methods below. Each mutates the draw / hit-test order in place, is a no-op for a node that isn’t a child, and runs in O(n):

parent.children.move_to(node, 2)   # to index 2, the rest shifting along
parent.children.move_first(node)   # index 0 → drawn first, hit-tested last
parent.children.move_last(node)    # last index → drawn last (on top), hit-tested first
parent.children.index(node)        # its position among its siblings
parent.children.clear()            # sever every link, running no lifecycle

clear() is a teardown call: it unlinks the children without exiting, destroying or notifying them, so it is the wrong one for anything a running scene can see. Use remove_child or destroy there.

Use move_last to bring a UI panel to the foreground when it gains focus; use move_first to push a background layer behind its siblings.

Signals

Signals provide decoupled event communication. A signal fires with emit(), and connect() returns a Connection handle for later disconnection:

from simvx.core import Node, Property, Signal

class Health(Node):
    hp = Property(100)
    died = Signal()

    def take_damage(self, amount):
        self.hp -= amount
        if self.hp <= 0:
            self.died.emit()

# Connect and get a Connection handle
health = Health()
conn = health.died.connect(lambda: print("Game over"))

# One-shot connection: auto-disconnects after first call
health.died.connect(on_first_death, once=True)

# Disconnect manually
conn.disconnect()

To re-emit one signal from another, connect the second signal’s emit: button.pressed.connect(self.play_requested.emit). Passing the signal itself raises a TypeError, because a signal is not a callable.

Groups

Tag nodes for batch operations:

enemy.add_to_group("enemies")
enemy.is_in_group("enemies")  # True

# Every member, in the order they joined the group
all_enemies = scene_tree.group("enemies")
first = scene_tree.first("enemies")  # the earliest member, or None

# Only the members that are an Enemy, typed list[Enemy]
for enemy in scene_tree.group("enemies", Enemy):
    enemy.take_damage(10)
    enemy.died.emit()

A group keeps its members in join order: a node joins when add_to_group runs while it is in the tree, or when it enters the tree already carrying the group. Leaving the group or the tree drops it, and joining again puts it last. destroy() drops a node from its groups at once, a frame before it leaves the tree.

The optional second argument, a class or a tuple of classes, keeps the members that are instances of it, so a group may mix node types and the loop is checked by your editor and type checker. A loop is how to act on every member: call a method, set a property or emit a signal. A runtime_checkable Protocol class as the filter matches by methods, not by inheritance. mypy refuses a Protocol where a concrete class is expected, so under mypy that call needs # type: ignore[type-abstract]; pyright accepts it.

group() returns a snapshot: a member that an earlier iteration of the loop destroys is still visited.

Coroutines

Generator-based coroutines for sequential async logic:

from simvx.core import wait, parallel, tween

def cutscene(self):
    yield from wait(1.0)                    # pause 1 second
    yield from tween(cam, "position", target, 2.0)  # animate
    yield from wait(0.5)
    self.start_dialog()

# Run multiple animations simultaneously
self.start_coroutine(parallel(
    tween(a, "position", pos_a, 1.0),
    tween(b, "position", pos_b, 1.0),
))

Destroying nodes

destroy() is deferred: the node stays alive and in the tree for the rest of the frame, and its whole subtree is carried out in one piece at the frame boundary. Every node still in that subtree is destroyed with it: its signal connections and event bus subscriptions end, and it cannot be added to a tree again. Move a child out first if it should outlive its parent.

enemy.destroy()
assert enemy.tree is not None    # still here, for the rest of this frame
assert enemy.destroying          # but on its way out

That is what makes it safe to call from a signal handler, a collision callback or a lifecycle hook, where tearing the node down on the spot would mutate a structure the caller is still walking.

Removal does not cost a node the events it was still owed, on either path: a physics body destroyed mid-touch is still the contact.other its peer’s separated receives, because the physics seam holds it for the step that delivers that event. remove_child() gets the same treatment (see Physics).

Calling it twice is a no-op. Calling it on a node whose parent is being destroyed in the same frame is accepted, and lands in the same place: each node’s on_exit_tree runs exactly once, whichever of the two was queued first. Use destroying to skip a node that is already queued.

remove_child() is the immediate counterpart, for reparenting and for the rare caller that needs the node gone before the call returns.

Finding nodes

Search downwards with find / find_all / expect. All three take the same target: a Node subclass (the result is typed as that subclass, so no cast is needed), a name, or a predicate (Node) -> bool. The search is depth-first, pre-order, and recursive unless direct=True limits it to direct children.

# First descendant matching, or None
camera = root.find(Camera3D)

# All descendants matching (recursive by default), or []
all_lights = root.find_all(Light3D)
# Restrict to direct children only
child_meshes = root.find_all(MeshInstance3D, direct=True)

expect() is find() for the case where a miss is a bug in the scene rather than a state to handle. It raises NodeNotFound, naming what it looked for, at the lookup instead of handing back a None that fails somewhere later:

from simvx.core import NodeNotFound

player = root.expect(Player)          # Player, not Player | None
hud = root.expect("HUD", direct=True)

try:
    root.expect("Boss")
except NodeNotFound as exc:
    ...  # the message names what was asked for: no descendant of 'Root' matches 'Boss'

ancestor() is the upward counterpart of find(), with the same matcher rules and the same typed result. It walks parents outwards from this node; self is never a candidate, and it returns None when nothing above matches. Use it to reach whatever a node lives under without hard-coding how deep it sits:

class Coin(Node2D):
    def collect(self):
        # Works wherever the coin is nested under the level.
        self.ancestor(Level).score += 1

Paths are the other axis: node_at() resolves a slash-separated path and raises NodeNotFound when a segment names no child. It takes a default exactly as getattr does: pass one and the miss returns it instead, for a node whose presence is optional.

camera.node_at("../Player")           # relative: raises if absent
minimap = root.node_at("HUD/Minimap", None)
if minimap is not None:
    minimap.refresh()

The two axes stay distinct: expect() searches a subtree for a type, a name or a predicate, while node_at() walks one exact path. find / expect is the pair for the search; the default is the pair for the path.

API reference

See simvx.core.node for the complete Node API.