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)
self.direction = Vec2.from_angle(random.uniform(0, math.tau))
self.colour = tuple(random.uniform(0.4, 1.0) for _ in range(3))
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
width, height = self.viewport_size
r = self.radius
if not r <= self.position.x <= width - r:
self.direction.x *= -1
self.position.x = clamp(self.position.x, r, width - r)
# ... 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.
So is the wall. self.viewport_size is the live size of whatever this node draws
into, read every frame rather than copied into a field: resize the window and the
balls bounce off the new edges, and any ball the smaller window left outside is
clamped back in on its next update. A module-level WIDTH, HEIGHT = 800, 600
driving this arithmetic would be right in the window the example opens at and
wrong in a resized one, on a phone, and in a browser canvas. The only window size
in the file is the one passed to App(width=800, height=600): the window you ask
for, which the balls never read.
Step 4: Spawn a crowd¶
The root builds the scene tree in on_ready(), adding eight balls at random
positions across the window (a ball that starts over an edge is clamped inside on
its first update). Each child then updates and draws itself: you write the behaviour once,
and every instance runs it.
class BouncingBalls(Node2D):
def on_ready(self):
width, height = self.viewport_size
for i in range(8):
self.add_child(Ball(name=f"Ball{i}", position=(random.uniform(0, width), random.uniform(0, height))))
viewport_size is valid from on_enter_tree() onward, which is before
on_ready(), so the spawn field is the window as it actually is. Off the tree it
raises rather than handing back a size nobody asked for.
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.circle((0, 0), self.radius, fill=self.colour)
class BouncingBalls(Node2D):
def on_draw(self, renderer):
renderer.text("Bouncing Balls", (10, 10), fill=(1.0, 1.0, 1.0), scale=2)
renderer.text(f"{len(self.children)} balls", (10, 44), fill=(0.71, 0.71, 0.71), scale=1)
on_draw draws in the node’s own space, so the ball paints its circle at its own
origin and the node’s position puts it on screen. The circle therefore follows
whatever on_update() did that frame with no separate sync step, and radius is
read at draw time, so editing it is felt immediately too.
Run it¶
# In your own copy of this directory
python main.py
# Anywhere, with the example library installed
simvx examples run tutorials_bouncing_balls
What’s next¶
Pong – combine input, signals, and collision into a complete game.
Source¶
1# /// simvx
2# tags = ["tutorial", "beginner", "properties"]
3# web = { width = 800, height = 600, responsive = true }
4# ///
5"""Bouncing Balls: Properties, velocity, and screen-edge collision.
6
7Eight colourful balls bounce off the edges of the window. Each one is its own node carrying its
8own state, and its tunable values are declared as `Property` descriptors.
9
10## What you will learn
11
12- **Property**: `radius = Property(12.0, range=(4, 40))` declares a value that is range-checked,
13 shown in the editor and saved with the scene. Read and write it like any attribute; an
14 assignment outside the range is clamped into it.
15- **on_update(dt)**: the per-frame callback. Each ball moves by `direction * speed * dt`, so its
16 speed is in pixels per second whatever the frame rate.
17- **viewport_size**: the live size of what the node draws into. It is read every frame, so a
18 resized window moves the walls, and a ball the smaller window left outside is clamped back in.
19- **add_child()**: the root builds its children in `on_ready()`, and each child then updates and
20 draws itself.
21- **on_draw(renderer)**: `renderer.circle` and `renderer.text` draw in the node's own space, so
22 a ball paints its circle at `(0, 0)` and its `position` places it on screen.
23
24## How it works
25
26A `Ball` keeps a unit `direction` and a colour chosen at random in `__init__`, and keeps its
27speed in the `speed` Property rather than baking a velocity. Because `on_update` reads both
28Properties every frame, editing `speed` or `radius` on a running ball changes it at once.
29When a ball crosses an edge it reverses that component of its direction and is clamped back
30inside the wall.
31
32`BouncingBalls` is the root: it scatters the balls across the window in `on_ready()` and draws a
33title and the ball count.
34"""
35
36import math
37import random
38
39from simvx.core import Node2D, Property, Vec2, clamp
40from simvx.graphics import App
41
42
43class Ball(Node2D):
44 radius = Property(12.0, range=(4, 40))
45 speed = Property(200.0, range=(50, 500))
46
47 def __init__(self, **kwargs):
48 super().__init__(**kwargs)
49 self.direction = Vec2.from_angle(random.uniform(0, math.tau))
50 self.colour = tuple(random.uniform(0.4, 1.0) for _ in range(3))
51
52 def on_update(self, dt: float):
53 self.position += self.direction * self.speed * dt
54
55 width, height = self.viewport_size
56 r = self.radius
57 if not r <= self.position.x <= width - r:
58 self.direction.x *= -1
59 self.position.x = clamp(self.position.x, r, width - r)
60 if not r <= self.position.y <= height - r:
61 self.direction.y *= -1
62 self.position.y = clamp(self.position.y, r, height - r)
63
64 def on_draw(self, renderer):
65 renderer.circle((0, 0), self.radius, fill=self.colour)
66
67
68class BouncingBalls(Node2D):
69 def on_ready(self):
70 width, height = self.viewport_size
71 for i in range(8):
72 # A ball that starts over an edge is clamped inside on its first update.
73 self.add_child(Ball(name=f"Ball{i}", position=(random.uniform(0, width), random.uniform(0, height))))
74
75 def on_draw(self, renderer):
76 renderer.text("Bouncing Balls", (10, 10), fill=(1.0, 1.0, 1.0), scale=2)
77 renderer.text(f"{len(self.children)} balls", (10, 44), fill=(0.71, 0.71, 0.71), scale=1)
78
79
80if __name__ == "__main__":
81 App(title="Bouncing Balls", width=800, height=600).run(BouncingBalls())