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¶
Node subclass: Override
on_ready()for setup,on_process(dt)for per-frame logic,on_draw(renderer)for 2D rendering. Theon_prefix is the canonical lifecycle hook form across the engine. Decorate other methods with@on_process/@on_input(action="jump")to register additional handlers.Input:
InputMap.add_action()binds named actions toKeyenums.Input.get_vector()returns a normalised direction.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¶
Your First 2D Game: Build a complete Pong game step by step
Your First 3D Game: Build a 3D asteroid dodger
Patterns: Cross-cutting idioms (autoloads, scene transitions, signals, decorator hooks)
WorldEnvironment: Bloom, SSAO, fog, tonemap, custom post-processing
Architecture: Render pipeline, scene graph, input flow internals