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. All lifecycle hooks use the on_ prefix to avoid shadowing Python builtins (input) and common method names (process, draw).

  1. Construction__init__() sets up properties

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

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

  4. Processon_update(dt) runs every frame

  5. Physics processon_fixed_update(dt) runs at fixed intervals (default 60 Hz)

  6. Inputon_input(event) then on_unhandled_input(event) for events not consumed by UI / earlier handlers

  7. Drawon_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 Your First 2D Game).

  8. Exit treedestroy() 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(action="jump"), @on_input(key=Key.ESCAPE, ctrl=True)). Decorated handlers run in declaration order; a truthy return from an @on_input handler consumes the event.

CharacterBody3D below is a physics node: a BodyMode.KINEMATIC body with a swept move_and_slide movement helper, not a separate kind of thing from PhysicsBody3D. Both of them, and Area3D, derive from PhysicsObject3D, which is the name for “a node with a body in the physics world” (see Physics).

import math

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

    def on_ready(self):
        self.health = 100

    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, serializable values with optional validation:

from simvx.core import Node3D, Property

class Tank(Node3D):
    speed = Property(5.0, range=(0, 20), hint="Movement speed")
    armor = Property("heavy", enum=["light", "medium", "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=[...]

string enum + inspector dropdown

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(...), "armor": 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: don’t reach for children._list, children.remove(...), or children.insert(...). Use the dedicated reorder methods on the collection. They mutate the draw / hit-test order in place, are no-ops for a node that isn’t a child, and run in O(n):

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

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. connect() returns a Connection handle for later disconnection:

from simvx.core import Signal

class HealthComponent(Node):
    def __init__(self):
        super().__init__()
        self.died = Signal()
        self.hp = 100

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

# Connect and get a Connection handle
health = HealthComponent()
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()

Groups

Tag nodes for batch operations:

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

# Get all nodes in a group
all_enemies = scene_tree.group("enemies")

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.

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.