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:
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), delivered in reverse tree order until a handler claims the eventDraw:
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 2D drawing).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("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 |
|---|---|
|
initial value (use |
|
numeric clamp + inspector field bounds |
|
make |
|
allowed values + inspector dropdown. Prefer a |
|
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(...), "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.