Architecture¶
SimVX is a Python game engine: node-based scene hierarchy, Vulkan GPU-driven rendering, and NumPy-based math. This page explains how the main pipelines fit together. For the API, start from Node system.
Node lifecycle¶
Every node progresses through these stages:
construction --> enter_tree --> ready --> [update / fixed_update] --> exit_tree
Construction:
__init__()sets properties andPropertydefaults. No tree access yet. Anything a node must own before it can be attached (a collider a physics body is built from, say) is built here.Enter tree:
add_child()inserts the node. The whole incoming subtree is bound to theSceneTreefirst (tree references, groups and unique names), and only then doeson_enter_tree()fire on each of its nodes, top-down. A hook may therefore look up nodes the walk has not visited yet, by group, by unique name or by path.Ready:
on_ready()runs bottom-up (children first, then parent). Safe to access children by path:self["Camera"].Process:
on_update(dt)runs every frame with wall-clock delta.on_fixed_update(dt)runs at a fixed rate (default 60 Hz) with accumulator-based catch-up.Exit tree:
destroy()marks the node for removal and disconnects its outgoing signals; at the frame boundaryon_exit_tree()fires, exactly once per node, and the subtree is carried out of theSceneTreein one piece.destroy()is final: the node is spent afterwards, andadd_child()refuses to put it back in a tree.
Children added during on_ready() go through the full lifecycle before the parent’s on_ready() returns. This guarantees that self["Player/Camera"] is valid inside on_ready().
The two entry hooks¶
They are not the same shape, and picking the wrong one is the common lifecycle bug:
|
|
|
|---|---|---|
Fires |
on every entry into a tree |
once per node instance, ever |
Sees |
the whole incoming subtree, bound but not yet readied |
its children readied, and the tree around it |
Belongs there |
per-stay setup: claiming a slot on the tree, subscribing to a tree signal, starting something |
one-time setup: building the children a node owns, caching lookups, connecting signals |
A node that leaves the tree and comes back re-enters but does not ready again, so children spawned in on_ready() are built once rather than once per entry. The mirror rule follows: whatever on_exit_tree() undoes must be redone in on_enter_tree(), never in on_ready().
examples/features/basics/lifecycle.py shows both hooks firing on a node that is added and removed on a repeating timer.
Scene tree¶
SceneTree owns the root node and drives the frame loop:
SceneTree.tick(dt) # walk tree, call on_update(dt) on every node
SceneTree.physics_tick(dt) # walk tree, call on_fixed_update(dt)
SceneTree.render(Draw2D) # 2D draw pass: re-runs on_draw only for dirty nodes (retained)
Driver (SceneTree.tick / physics_tick / render) and handler (Node.on_update / on_fixed_update / on_draw) deliberately use different verbs so a misspelt override on a Node never silently shadows the engine’s per-frame loop.
Groups allow batch queries: tree.group("enemies") returns all nodes tagged with that group, in the order they joined it. The tree also manages deferred deletions: destroy() queues removal until the end of the current frame to avoid mutation during iteration.
Render pipeline¶
SimVX uses a GPU-driven forward renderer. The per-frame work driven from
Python is bounded: scene state goes into flat NumPy arrays once, then a
single multi-draw-indirect call submits every visible instance. There are
no per-object vkCmdDraw loops in Python.
The public contract is: GPU-driven, one indirect submission for opaque
geometry, a separate sorted pass for transparent. Buffer sizes, descriptor
counts, culling strategy, and pass ordering are implementation details
that may change without notice: read the code in
packages/graphics/src/simvx/graphics/renderer/ if you need the current
specifics.
2D is retained. A node’s on_draw(renderer) is not re-run every frame:
the renderer caches the geometry it produced and re-runs on_draw only when
the node is marked dirty. A Property or transform write auto-dirties the
node; per-frame animation reading non-Property state opts in with
dynamic = True; a discrete non-Property change calls queue_redraw() at
the mutation site. See retained drawing.
Input flow¶
backend / simulator / on-screen pad --> InputRouter.publish --> five phases --> game code
InputRouter.publish(event) is the only way input enters the engine. Window backends, the
simulator, AI agents, on-screen controls and replays all call it with one typed event
(KeyEvent, TextEvent, PointerEvent, ScrollEvent, ButtonEvent, AxisEvent,
PickEvent). Input is pumped once per frame, before the physics tick, and each
event dispatches synchronously inside that pump. One router per OS window; several may feed one
tree.
Each event runs five phases:
Device state, always written.
Input.is_key_pressed(Key.W)reads this and is never gated.Targeted: the pointer’s captor, else the UI hit test bubbling outward through
on_gui_input, elseon_pickedon the nearest pickable node under the ray; for a key, the focus owner and then theui_*fallbacks. A controller button takes the focus route too, which is what lets a pad player leave a title screen. A claim here means no action state, no edge and no broadcast.Resolve: the flattened context stack writes action state and edges.
Broadcast: every
@on_inputhandler the event matches, in reverse tree order: deepest and last-added first, the root last. A claim stops the rest.Record and notify, with
consumed_byfinal.
A key repeat runs the targeted phase only, so a held key repeats in a text field without a game action firing twice. A publish made during dispatch is queued and drained in order after the current event completes.
Honest and routed. Device-named state (is_key_pressed, mouse_position, Input.controllers)
is never gated by a claim; everything resolved from a binding or delivered to a handler is. To make
input vanish from the game, use focus, an overlay, a context’s hides, or the node’s update mode.
See Input for the action layer, contexts and players.
Performance notes¶
Node counts: The tree walk is pure Python, so per-frame cost grows with the number of nodes that have an update hook. Use groups rather than repeated deep searches.
Draw call batching: All opaque geometry is drawn in a single indirect draw call regardless of material count. Only transparent objects require sorting.
Buffer capacity: Instance, material and joint buffers start at a default capacity and grow when a scene exceeds it, at the cost of a one-time stall.
Physics tick: 60 Hz by default (
App(physics_fps=...)). A slow frame runs several physics steps to catch up; a frame’s elapsed time is capped at 100 ms so a stall cannot snowball.Headless mode:
App(visible=False)creates a real Vulkan surface with an invisible GLFW window. Rendering is identical to visible mode, which suits automated visual tests in CI.