Bouncing Balls

Properties, velocity, and screen-edge collision.

▶ Run in browser

Tags: tutorial beginner properties

Bouncing Balls

You can move one node now. This tutorial spawns many nodes that each carry their own state, and introduces Property descriptors: the engine’s way to declare a value that is type-checked, range-validated, editor-visible, and serialised, all at once.

By the end you will have a window of colourful balls bouncing off the edges, each running its own movement and bounce logic.

Step 1: Declare properties

A Property looks like a class attribute with a default and an optional range. The engine validates assignments against the range and shows the value in the editor:

class Ball(Node2D):
    radius = Property(12.0, range=(4, 40))
    speed  = Property(200.0, range=(50, 500))

You read and write a property like a normal attribute (self.radius); the descriptor does the validation behind the scenes.

Step 2: Give each ball its own state

__init__ runs once per ball. Each picks a random direction and colour, so every ball is independent even though they share a class:

def __init__(self, **kwargs):
    super().__init__(**kwargs)
    angle = random.uniform(0, math.tau)
    self.direction = Vec2(math.cos(angle), math.sin(angle))
    self.colour = (
        random.uniform(0.4, 1.0),
        random.uniform(0.4, 1.0),
        random.uniform(0.4, 1.0),
        1.0,
    )

Note what is not stored here: the ball keeps a unit direction, not a finished velocity. Speed stays in the speed property and is read fresh every frame, so changing it later actually changes how the ball moves.

Step 3: Move and bounce each frame

on_update(dt) advances the ball and flips its direction when it reaches an edge, clamping the position so it never escapes the window. clamp() comes from simvx.core:

def on_update(self, dt):
    self.position += self.direction * self.speed * dt
    if self.position.x < self.radius or self.position.x > WIDTH - self.radius:
        self.direction.x *= -1
        self.position.x = clamp(self.position.x, self.radius, WIDTH - self.radius)
    # ... same for y

Both properties are live here: speed sets the distance covered each frame, and radius decides where the edge is. Edit either one and the running ball responds.

Step 4: Spawn a crowd

The root builds the scene tree in on_ready(), adding eight balls at random positions. Each child then updates and draws itself: you write the behaviour once, and every instance runs it.

class BouncingBalls(Node2D):
    def on_ready(self):
        for i in range(8):
            self.add_child(Ball(name=f"Ball{i}",
                                position=Vec2(random.uniform(50, WIDTH - 50),
                                              random.uniform(50, HEIGHT - 50))))

Step 5: Draw each ball

Nothing is visible until a node draws. on_draw(renderer) hands you immediate-mode primitives: here a filled circle for the ball, and text on the root for a title and a live count.

SimVX keeps what a node drew and reuses it, so on_draw runs again only when the node changes: when you move it, when a Property it draws from changes, or when you call queue_redraw(). These balls move every frame, so they redraw every frame. A node that animates from plain attributes the engine cannot see would freeze on its first frame, and the fix is to call queue_redraw() when you change them (or set dynamic = True to redraw unconditionally).

class Ball(Node2D):
    def on_draw(self, renderer):
        renderer.draw_circle(self.position, self.radius, colour=self.colour, filled=True)


class BouncingBalls(Node2D):
    def on_draw(self, renderer):
        renderer.draw_text("Bouncing Balls", (10, 10), scale=2, colour=(1.0, 1.0, 1.0))
        renderer.draw_text(f"{len(self.children)} balls", (10, 35), scale=1, colour=(0.71, 0.71, 0.71))

Because position and radius are read at draw time, the circle follows whatever on_update() did that frame: there is no separate sync step to remember.

Run it

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

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

What’s next

  • Pong – combine input, signals, and collision into a complete game.

Source

  1"""Bouncing Balls: Properties, velocity, and screen-edge collision.
  2
  3Spawn colourful balls that bounce off screen edges. Demonstrates the
  4`Property` descriptor for editor-visible values and basic frame-by-frame
  5movement with `on_update()`.
  6
  7# /// simvx
  8# tags = ["tutorial", "beginner", "properties"]
  9# web = { root = "BouncingBalls", width = 800, height = 600, responsive = true }
 10# ///
 11
 12## What You Will Learn
 13
 14- **Property** -- Declare editor-visible properties with validation ranges
 15- **on_update(dt)** -- Per-frame update callback with delta time
 16- **position** -- Move nodes by updating `self.position` each frame
 17- **add_child()** -- Build a scene tree dynamically in `on_ready()`
 18- **draw_circle()** -- Render filled circles
 19
 20## How It Works
 21
 22`Ball` declares `radius` and `speed` as `Property` descriptors with value
 23ranges. In `__init__`, a random unit direction and colour are chosen. Each
 24frame, `on_update(dt)` advances the ball by `direction * speed * dt` and
 25reflects the direction when the ball hits a screen edge, so editing either
 26property takes effect immediately.
 27
 28`BouncingBalls` is the root node that spawns 8 `Ball` children in `on_ready()`,
 29placing them at random positions. The parent also draws a title and ball
 30count via `draw_text()`.
 31
 32Run: uv run python examples/tutorials/bouncing_balls/main.py
 33Headless self-check: uv run python examples/tutorials/bouncing_balls/main.py --test
 34"""
 35
 36import math
 37import random
 38
 39from simvx.core import Node2D, Property, Vec2, clamp
 40from simvx.graphics import App
 41
 42WIDTH, HEIGHT = 800, 600
 43
 44
 45class Ball(Node2D):
 46    radius = Property(12.0, range=(4, 40))
 47    speed = Property(200.0, range=(50, 500))
 48
 49    def __init__(self, **kwargs):
 50        super().__init__(**kwargs)
 51        angle = random.uniform(0, math.tau)
 52        self.direction = Vec2(math.cos(angle), math.sin(angle))
 53        self.colour = (
 54            random.uniform(0.4, 1.0),
 55            random.uniform(0.4, 1.0),
 56            random.uniform(0.4, 1.0),
 57            1.0,
 58        )
 59
 60    def on_update(self, dt: float):
 61        # Both properties are read every frame, so editing either one is felt straight away.
 62        self.position += self.direction * self.speed * dt
 63
 64        # Bounce off walls
 65        if self.position.x < self.radius or self.position.x > WIDTH - self.radius:
 66            self.direction.x *= -1
 67            self.position.x = clamp(self.position.x, self.radius, WIDTH - self.radius)
 68        if self.position.y < self.radius or self.position.y > HEIGHT - self.radius:
 69            self.direction.y *= -1
 70            self.position.y = clamp(self.position.y, self.radius, HEIGHT - self.radius)
 71
 72    def on_draw(self, renderer):
 73        renderer.draw_circle(self.position, self.radius, colour=self.colour, filled=True)
 74
 75
 76class BouncingBalls(Node2D):
 77    def on_ready(self):
 78        for i in range(8):
 79            self.add_child(
 80                Ball(
 81                    name=f"Ball{i}",
 82                    position=Vec2(random.uniform(50, WIDTH - 50), random.uniform(50, HEIGHT - 50)),
 83                )
 84            )
 85
 86    def on_draw(self, renderer):
 87        renderer.draw_text("Bouncing Balls", (10, 10), scale=2, colour=(1.0, 1.0, 1.0))
 88        renderer.draw_text(f"{len(self.children)} balls", (10, 35), scale=1, colour=(0.71, 0.71, 0.71))
 89
 90
 91def _selftest() -> bool:
 92    """Headless: run the swarm and check the three claims the lesson makes.
 93
 94    That a ball travels at its ``speed`` Property, that it stays inside the window,
 95    and that editing a Property mid-run is felt on the very next frame -- which is
 96    the point of reading them in ``on_update`` rather than caching them. The run is
 97    seeded so the same eight balls are measured every time.
 98    """
 99    from simvx.graphics.testing import assert_not_blank, save_png
100
101    SLOW_TO = Ball.speed.range[0]  # the Property's own floor: a legal value to edit it to
102    WINDOWS = ((2, 32, Ball.speed.default), (40, 100, SLOW_TO))  # (from, to, the speed in force)
103    RESTORE, TOTAL = 110, 700  # back to full speed, then long enough that every ball reaches a wall
104
105    random.seed(7)
106    app = App(title="Bouncing Balls", width=WIDTH, height=HEIGHT, visible=False)
107    scene = BouncingBalls(name="BouncingBalls")
108    seen: dict[str, object] = {}
109    escapes = 0
110    bounced: set[str] = set()
111    heading: dict[str, tuple[float, float]] = {}
112
113    def snapshot(balls) -> dict[str, tuple[float, float]]:
114        return {b.name: (float(b.position.x), float(b.position.y)) for b in balls}
115
116    def on_frame(idx: int, _t: float) -> bool:
117        nonlocal escapes
118        balls = [c for c in scene.children if isinstance(c, Ball)]
119        if idx == 0:
120            seen["balls"] = len(balls)
121        for start, end, speed in WINDOWS:
122            if idx == start:
123                # The lesson's claim: edit a Property and the next frame uses it.
124                for ball in balls:
125                    ball.speed = speed
126                seen[f"from{start}"] = snapshot(balls)
127                seen[f"turned{start}"] = set()
128            elif idx == end:
129                seen[f"to{start}"] = snapshot(balls)
130        if idx == RESTORE:
131            for ball in balls:
132                ball.speed = Ball.speed.default
133        for ball in balls:
134            r, x, y = float(ball.radius), float(ball.position.x), float(ball.position.y)
135            if idx > 0 and not (r - 0.01 <= x <= WIDTH - r + 0.01 and r - 0.01 <= y <= HEIGHT - r + 0.01):
136                escapes += 1
137            now = (float(ball.direction.x), float(ball.direction.y))
138            if heading.get(ball.name, now) != now:
139                bounced.add(ball.name)
140                for start, end, _ in WINDOWS:
141                    if start < idx <= end:
142                        seen[f"turned{start}"].add(ball.name)
143            heading[ball.name] = now
144        return True
145
146    frames = app.run_headless(scene, frames=TOTAL, on_frame=on_frame, capture_frames=[TOTAL - 1])
147    assert_not_blank(frames[0])
148    save_png(frames[0], "/tmp/bouncing_balls_test.png")
149
150    ok = True
151
152    def check(label: str, passed: bool, detail: str) -> None:
153        nonlocal ok
154        ok = ok and passed
155        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
156
157    check("on_ready spawns the eight balls the lesson describes", seen["balls"] == 8, f"{seen['balls']} Ball children")
158
159    def travelled(start: int, end: int) -> dict[str, float]:
160        """How far each ball that met no wall moved between two frames."""
161        return {
162            name: math.dist(seen[f"from{start}"][name], seen[f"to{start}"][name])
163            for name in seen[f"from{start}"]
164            if name not in seen[f"turned{start}"]
165        }
166
167    labels = (
168        "each ball travels at its speed Property, in the direction it was given",
169        "and setting speed mid-run is felt at once, because on_update reads the Property every frame",
170    )
171    for label, (start, end, speed) in zip(labels, WINDOWS, strict=True):
172        moved = travelled(start, end)
173        elapsed = (end - start) / 60.0
174        check(
175            label,
176            bool(moved) and all(abs(d - speed * elapsed) < 1.0 for d in moved.values()),
177            f"{len(moved)} balls that met no wall moved {min(moved.values()):.1f}..{max(moved.values()):.1f}px "
178            f"in {elapsed:.2f}s at speed {speed:.0f} (expected {speed * elapsed:.1f})",
179        )
180
181    check(
182        "every ball reaches a wall, turns round, and none ever leaves the window",
183        escapes == 0 and len(bounced) == seen["balls"],
184        f"{len(bounced)}/{seen['balls']} balls bounced over {TOTAL} frames, with {escapes} frames outside the window",
185    )
186
187    print("screenshot: /tmp/bouncing_balls_test.png")
188    print("SELFTEST:", "PASS" if ok else "FAIL")
189    return ok
190
191
192if __name__ == "__main__":
193    import sys
194
195    if "--test" in sys.argv:
196        sys.exit(0 if _selftest() else 1)
197    App(title="Bouncing Balls", width=WIDTH, height=HEIGHT).run(BouncingBalls())