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_process(dt) runs every frame

  5. Physics processon_physics_process(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) is called every frame for 2D draw commands

  8. Exit treedestroy() triggers on_exit_tree(); children are recursively removed at the end of the frame

Multiple handlers for the same hook can be registered with decorators (@on_process, @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.

import math

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

    def on_ready(self):
        self.health = 100

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

    def on_physics_process(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, old, new):
        if new <= 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 slider range

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, process_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.get_node("../Player")  # relative path (sibling)
camera.get_node("/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.get_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

Remove nodes safely at the end of the frame:

enemy.destroy()

Finding Nodes

# Find first child of type
camera = root.find(Camera3D)

# Find all descendants of type
all_lights = root.find_all(Light3D)
all_meshes = root.find_all(MeshInstance3D, recursive=True)

API Reference

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