First Scene

Open a window and add a node.

▶ Run in browser

Tags: beginner tutorial

First Scene

Your first SimVX scene in under thirty lines. By the end you will have opened a window, drawn a pulsing circle, and seen three of the lifecycle hooks every node has access to.

Step 1: Subclass Node2D

Every visible thing in SimVX is a node. Node2D is the 2D-aware variant: it knows about position, rotation, and lets you override on_draw() to issue immediate-mode draw commands.

from simvx.core import Node2D

class FirstScene(Node2D):
    pass

Step 2: Hook into the frame loop

Three hooks fire automatically once the node is in the scene tree:

  • on_ready(): called once after the node enters the tree. Initialise state here.

  • on_update(dt): called every frame with the seconds since the last frame.

  • on_draw(renderer): issue draw commands here. The 2D renderer is retained: it re-runs on_draw only when the node changes, so a node whose drawing animates every frame (like our pulsing circle) sets dynamic = True to opt into per-frame redraws.

import math

class FirstScene(Node2D):
    dynamic = True  # the circle pulses every frame, so redraw it every frame

    def on_ready(self):
        self.t = 0.0

    def on_update(self, dt: float):
        self.t += dt

    def on_draw(self, renderer):
        radius = 80 + 20 * math.sin(self.t * 2)  # gentle pulse, 60..100 px
        renderer.draw_circle((400, 300), radius, colour=(0.4, 0.8, 1.0, 1.0), filled=True)
        renderer.draw_text("Welcome to SimVX", (400, 440), scale=2, alignment="centre", colour=(1.0, 1.0, 1.0))

alignment="centre" anchors the text’s centre on the x you pass, so there is no width arithmetic to hand-tune.

Step 3: Launch it

App creates a Vulkan-backed window and runs your scene tree.

from simvx.graphics import App

App(title="First Scene", width=800, height=600).run(FirstScene())

Run it

# In your own copy of this directory
python main.py

# From the root of a repository checkout
uv run python examples/tutorials/first_scene/main.py

A window opens with a pulsing blue circle and a welcome message.

What’s next

  • Nodes and Signals – the next tutorial: wire several nodes together so they react to each other.

  • Input and Movement – drive a node from the keyboard with input actions.

  • Bouncing BallsProperty descriptors and many children at once.

Source

  1"""First Scene: Open a window and add a node.
  2
  3A two-minute tour: subclass `Node2D`, override `on_draw()` to paint a pulsing
  4circle, launch with `App`, and meet the lifecycle hooks called along the way.
  5
  6# /// simvx
  7# tags = ["beginner", "tutorial"]
  8# web = { root = "FirstScene", width = 800, height = 600, responsive = true }
  9# ///
 10
 11Run: uv run python examples/tutorials/first_scene/main.py
 12Headless self-check: uv run python examples/tutorials/first_scene/main.py --test
 13"""
 14
 15import math
 16
 17from simvx.core import Node2D
 18from simvx.graphics import App
 19
 20WIDTH, HEIGHT = 800, 600
 21
 22
 23class FirstScene(Node2D):
 24    # The circle pulses every frame -> opt this node into per-frame redraw so the
 25    # retained 2D renderer re-runs on_draw each frame instead of freezing the geometry.
 26    dynamic = True
 27
 28    def on_ready(self):
 29        self.t = 0.0
 30
 31    def on_update(self, dt: float):
 32        self.t += dt
 33
 34    def on_draw(self, renderer):
 35        cx, cy = WIDTH / 2, HEIGHT / 2
 36        radius = 80 + 20 * math.sin(self.t * 2)  # gentle pulse, 60..100 px
 37        renderer.draw_circle((cx, cy), radius, colour=(0.4, 0.8, 1.0, 1.0), filled=True)
 38        # alignment="centre" anchors the text's centre on cx, so no width maths.
 39        renderer.draw_text("Welcome to SimVX", (cx, cy + 140), scale=2, alignment="centre", colour=(1.0, 1.0, 1.0))
 40
 41
 42def _selftest() -> bool:
 43    """Headless: run the scene and check the circle really pulses on screen.
 44
 45    The lesson is that ``on_update`` advances state and ``on_draw`` paints it, so
 46    both halves are checked from the rendered frames: one captured where the pulse
 47    is widest and one where it is narrowest, and the circle's own pixels counted in
 48    each. A frozen ``on_draw`` would give two identical counts.
 49    """
 50    import numpy as np
 51
 52    from simvx.graphics.testing import assert_not_blank, save_png
 53
 54    # radius = 80 + 20*sin(2t): widest a quarter of the way through the cycle,
 55    # narrowest three quarters through, at 60 frames a second.
 56    WIDEST, NARROWEST = round(math.pi / 4 * 60), round(3 * math.pi / 4 * 60)
 57
 58    app = App(title="First Scene", width=WIDTH, height=HEIGHT, visible=False)
 59    scene = FirstScene(name="FirstScene")
 60    seen: dict[str, object] = {}
 61
 62    def on_frame(idx: int, _t: float) -> bool:
 63        if idx == WIDEST:
 64            seen["t_widest"] = scene.t
 65        elif idx == NARROWEST:
 66            seen["t_narrowest"] = scene.t
 67        return True
 68
 69    frames = app.run_headless(scene, frames=NARROWEST + 2, on_frame=on_frame, capture_frames=[WIDEST, NARROWEST])
 70    assert_not_blank(frames[0])
 71    save_png(frames[0], "/tmp/first_scene_test.png")
 72
 73    ok = True
 74
 75    def check(label: str, passed: bool, detail: str) -> None:
 76        nonlocal ok
 77        ok = ok and passed
 78        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
 79
 80    def circle_pixels(frame) -> int:
 81        """Pixels painted in the circle's blue, which nothing else in the scene uses."""
 82        r, g, b = (frame[..., i].astype(np.int16) for i in range(3))
 83        return int(np.count_nonzero((b > 180) & (b > r + 60) & (g > r)))
 84
 85    check(
 86        "on_update advances the scene clock by the frame time",
 87        abs(seen["t_widest"] - WIDEST / 60.0) < 0.02 and abs(seen["t_narrowest"] - NARROWEST / 60.0) < 0.02,
 88        f"{seen['t_widest']:.2f}s at frame {WIDEST}, {seen['t_narrowest']:.2f}s at frame {NARROWEST}",
 89    )
 90
 91    wide, narrow = circle_pixels(frames[0]), circle_pixels(frames[1])
 92    check(
 93        "the circle is on screen and its area swings between the two radii the pulse names",
 94        abs(wide - math.pi * 100**2) / (math.pi * 100**2) < 0.05
 95        and abs(narrow - math.pi * 60**2) / (math.pi * 60**2) < 0.05,
 96        f"{wide} px at its widest (a 100px circle is {math.pi * 100**2:.0f}), "
 97        f"{narrow} px at its narrowest ({math.pi * 60**2:.0f})",
 98    )
 99    check(
100        "so the frame really is repainted rather than frozen after the first one",
101        wide > narrow,
102        f"{wide - narrow} pixels of difference between the two captured frames",
103    )
104
105    print("screenshot: /tmp/first_scene_test.png")
106    print("SELFTEST:", "PASS" if ok else "FAIL")
107    return ok
108
109
110if __name__ == "__main__":
111    import sys
112
113    if "--test" in sys.argv:
114        sys.exit(0 if _selftest() else 1)
115    App(title="First Scene", width=WIDTH, height=HEIGHT).run(FirstScene())