Bouncing Balls¶
Properties, velocity, and screen-edge collision.
▶ Run in browserTags: 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())