Quickstart

Get a window open in under 20 lines. Everything the engine exposes lives under two roots: simvx.core (nodes, signals, properties, math, input, audio, UI) and simvx.graphics (the Vulkan App window). Import only what you use.

2D: Moving Rectangle

from simvx.core import Node2D, InputMap, Key, Input, Vec2
from simvx.graphics import App

class Player(Node2D):
    def on_ready(self):
        InputMap.add_action("left", [Key.A, Key.LEFT])
        InputMap.add_action("right", [Key.D, Key.RIGHT])
        InputMap.add_action("up", [Key.W, Key.UP])
        InputMap.add_action("down", [Key.S, Key.DOWN])

    def on_process(self, dt):
        self.position += Input.get_vector("left", "right", "up", "down") * 200 * dt

    def on_draw(self, renderer):
        renderer.draw_rect((self.position.x, self.position.y), (60, 60), colour=(0.2, 0.4, 1.0))

App(width=800, height=600, title="Quickstart").run(Player(position=Vec2(370, 270)))

Save as quickstart.py and run:

uv run python quickstart.py

3D: Spinning Cube

import math
from simvx.core import Node, Camera3D, MeshInstance3D, Mesh, Material
from simvx.graphics import App

class Game(Node):
    def on_ready(self):
        cam = Camera3D(position=(0, 3, 8))
        cam.look_at((0, 0, 0))
        self.add_child(cam)
        self.cube = self.add_child(MeshInstance3D(mesh=Mesh.cube(), material=Material(colour=(1, 0, 0))))

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

App(width=1280, height=720, title="3D Quickstart").run(Game())

Rotation APIs take radians; use math.radians() when thinking in degrees.

What’s Happening

  1. Node subclass: Override on_ready() for setup, on_process(dt) for per-frame logic, on_draw(renderer) for 2D rendering. The on_ prefix is the canonical lifecycle hook form across the engine. Decorate other methods with @on_process / @on_input(action="jump") to register additional handlers.

  2. Input: InputMap.add_action() binds named actions to Key enums. Input.get_vector() returns a normalised direction.

  3. App: Creates a Vulkan window and runs the game loop. One line to launch.

API at a glance

A working overview for an experienced dev: every entry is a real, current API. Deep-dive links follow each section.

Node lifecycle (Node System)

class Player(Node3D):
    def on_ready(self):              # bottom-up, after children ready
        ...
    def on_process(self, dt):        # every frame (wall-clock)
        ...
    def on_physics_process(self, dt):# fixed timestep (default 60 Hz)
        ...
    def on_input(self, event): ...           # raw input (UI sees first)
    def on_unhandled_input(self, event): ... # only if no one consumed it
    def on_draw(self, renderer): ...         # 2D draw commands
    def on_enter_tree(self): ...
    def on_exit_tree(self): ...

Decorators register additional handlers under any method name (see Patterns):

@on_process
def update_anim(self, dt): ...

@on_input(action="jump")           # filter by InputMap action
@on_input(key=Key.S, ctrl=True)    # filter by raw key + modifiers
def jump(self, event): return True # truthy return consumes the event

Properties & Signals

class Enemy(Node):
    speed = Property(5.0, range=(0, 20), hint="px/sec")
    rank  = Property("grunt", enum=["grunt", "elite", "boss"])
    hp    = Property(100, on_change="_on_hp", persist=True)

    died     = Signal()                # no args
    damaged  = Signal[int]             # bracket form == Signal(int)
    hit_by   = Signal(int, str)        # (amount, source)

    def _on_hp(self, old, new):
        if new <= 0: self.died.emit()

conn = enemy.died.connect(self.cleanup)              # → Connection
enemy.damaged.connect(handler, once=True)            # auto-disconnect
conn.disconnect()

Property kwargs: range, enum, hint, on_change, link, propagate, persist, save_version, default_factory, group. Arity is checked at connect() against the signal’s declared types.

Tree access

self.add_child(node)             # also: returns node, supports kwargs
self.parent                      # direct parent
self.tree                        # SceneTree
self.app                         # graphics.App (after on_enter_tree)
self["Player/Camera"]            # path lookup
self.find(Camera3D)              # first descendant of type (recursive=True)
self.find_all(Light3D)           # all descendants of type
self.find_child("HUD")           # by name (recursive=False by default)
self.add_to_group("enemies"); self.tree.get_group("enemies")
self.destroy()                   # safe end-of-frame removal

Input (Input System)

InputMap.add_action("jump", [Key.SPACE, JoyButton.A])   # in on_ready, never module scope
Input.is_action_pressed("jump")
Input.is_action_just_pressed("jump")
Input.get_strength("accelerate")                         # 0.0–1.0
Input.get_axis("left", "right")                          # -1..1
Input.get_vector("left", "right", "up", "down")          # Vec2

Coroutines

from simvx.core import wait, wait_until, wait_signal, next_frame, parallel, tween

def cutscene(self):
    yield from wait(1.0)
    yield from tween(self.cam, "position", target, 2.0)
    yield from wait_signal(self.boss.died)
    yield from parallel(
        tween(a, "position", pa, 1.0),
        tween(b, "position", pb, 1.0),
    )
self.start_coroutine(cutscene())

Scene tree, autoloads, scene swap (Patterns)

app.tree.add_autoload("GameState", GameState())   # survives change_scene
self.tree.autoloads["GameState"].score += 100
self.tree.change_scene(GameOverScreen(score=...))

Rendering setup (WorldEnvironment, App Class)

env = self.add_child(WorldEnvironment())
env.bloom_enabled = True; env.bloom_threshold = 0.8
env.tonemap_mode = "aces"; env.ssao_enabled = True

App(width=1280, height=720, title="Game",
    physics_fps=60, target_fps=None, vsync=True, visible=True).run(root)

# Headless (CI / visual testing)
frames = App(visible=False).run_headless(root, frames=10, capture_frames=[0, 9])

Math conventions

Vec2 / Vec3 are numpy.ndarray subclasses (float32); Quat for rotations. All angles are radians internally: wrap with math.radians() when thinking in degrees. Matrices are row-major; the renderer transposes at the GPU boundary.

Next Steps