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
  1. Construction: __init__() sets properties and Property defaults. 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.

  2. Enter tree: add_child() inserts the node. The whole incoming subtree is bound to the SceneTree first (tree references, groups and unique names), and only then does on_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.

  3. Ready: on_ready() runs bottom-up (children first, then parent). Safe to access children by path: self["Camera"].

  4. 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.

  5. Exit tree: destroy() marks the node for removal and disconnects its outgoing signals; at the frame boundary on_exit_tree() fires, exactly once per node, and the subtree is carried out of the SceneTree in one piece. destroy() is final: the node is spent afterwards, and add_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:

on_enter_tree()

on_ready()

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 on_exit_tree() stops

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:

  1. Device state, always written. Input.is_key_pressed(Key.W) reads this and is never gated.

  2. Targeted: the pointer’s captor, else the UI hit test bubbling outward through on_gui_input, else on_picked on the nearest pickable node under the ray; for a key, the focus owner and then the ui_* 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.

  3. Resolve: the flattened context stack writes action state and edges.

  4. Broadcast: every @on_input handler the event matches, in reverse tree order: deepest and last-added first, the root last. A claim stops the rest.

  5. Record and notify, with consumed_by final.

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.