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).
Construction –
__init__()sets up propertiesEnter tree – Node is added via
add_child();on_enter_tree()firesReady –
on_ready()is called once, after all children are ready (bottom-up)Process –
on_update(dt)runs every framePhysics process –
on_fixed_update(dt)runs at fixed intervals (default 60 Hz)Input –
on_input(event)thenon_unhandled_input(event)for events not consumed by UI / earlier handlersDraw –
on_draw(renderer)emits 2D draw commands. The 2D renderer is retained: it re-runson_drawonly when the node is dirty. APropertyor transform write auto-dirties; for per-frame animation setdynamic = True, and for a discrete non-Propertychange callqueue_redraw()at the mutation site (see Your First 2D Game).Exit tree –
destroy()marks the node; at the frame boundaryon_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 |
|---|---|
|
initial value (use |
|
numeric clamp + inspector field bounds |
|
make |
|
string enum + inspector dropdown |
|
tooltip text shown in the editor inspector |
|
name of a zero-arg bound method invoked after each value change. Hooks fired while |
|
child’s resolved value is offset from the parent’s same-named property (cumulative scale, accumulated tint, etc.) |
|
bool/enum properties inherit disabling values from parents (e.g. |
|
included in |
|
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.