Nodes and Signals¶
build a scene tree and wire nodes together with signals.
▶ Run in browserTags: tutorial beginner signals nodes
Nodes and Signals¶
Your first window showed a single node drawing itself. Real games are many nodes that need to react to each other: a score label updates when an enemy dies, a health bar shrinks when the player is hit. The clean way to wire that up is signals, and this tutorial builds the smallest example that shows why.
By the end you will have a scene tree of three nodes where a Player drives a
HealthBar without ever referencing it.
Step 1: Build a scene tree¶
Nodes form a tree. A node becomes part of the running game when you add_child()
it onto a node already in the tree. The root Game node adds two children in
on_ready():
class Game(Node2D):
def on_ready(self):
self.player = self.add_child(Player(position=Vec2(400, 300)))
self.bar = self.add_child(HealthBar(position=Vec2(250, 60)))
add_child() returns the child, so you can keep a handle to it. Every child
gets its own on_ready(), on_update(dt) and on_draw(renderer) hooks.
Step 2: Declare a signal¶
A signal is an event a node can announce. Create one as an attribute in
on_ready() (children are made ready before their parent wires them, so the
signal exists in time to connect) and emit() it when the event happens:
class Player(Node2D):
def on_ready(self):
self.max_health = 100
self.health = self.max_health
self.health_changed = Signal() # carries (current, maximum)
self.died = Signal() # bare "it happened" event
def take_damage(self, amount):
self.health = max(0, self.health - amount)
self.health_changed.emit(self.health, self.max_health)
if self.health == 0:
self.died.emit()
The Player announces what happened. It does not know or care who is
listening.
Step 3: Connect a listener¶
The HealthBar exposes a plain method and draws whatever it was last told:
class HealthBar(Node2D):
def on_ready(self):
self._ratio = 1.0
def on_health_changed(self, current, maximum):
self._ratio = current / maximum
self.queue_redraw()
on_draw() output is retained between frames, so a node that draws from plain
state (here _ratio, which is not a Property) has to call queue_redraw()
when that state changes. Leave it out and the bar is drawn once at full health
and never updates again.
The root wires the emitter to the listener with connect():
self.player.health_changed.connect(self.bar.on_health_changed)
self.player.died.connect(self._on_player_died)
Now every take_damage() fans out to every connected listener. Add a second
HealthBar and connect it too: no change to Player. That decoupling is the
whole reason signals exist.
Step 4: Drive it¶
Game.on_update(dt) damages the player once a second, and revives it when it
dies, so you can watch the bar drain and reset:
def on_update(self, dt):
self._elapsed += dt
if self._elapsed >= 1.0:
self._elapsed = 0.0
self.player.take_damage(10)
def _on_player_died(self):
self.player.revive()
Accumulating dt by hand keeps the frame loop visible, which is the point
here. Once it is familiar, reach for the engine’s Timer node instead: it
counts down for you and emits a timeout signal you connect exactly like the
two above.
Run it¶
# In your own copy of this directory
python main.py
# From the root of a repository checkout
uv run python examples/tutorials/nodes_and_signals/main.py
What’s next¶
Input and Movement – drive a node from the keyboard with input actions.
Bouncing Balls –
Propertydescriptors and many children at once.
Source¶
1"""Nodes and Signals: build a scene tree and wire nodes together with signals.
2
3The second thing to learn after opening a window: how nodes form a tree, and how
4they talk to each other WITHOUT knowing about one another, using signals.
5
6A `Player` loses health on a timer and announces it by emitting a `health_changed`
7signal. A separate `HealthBar` redraws itself whenever it receives that signal. The
8`Player` never references the `HealthBar`: the root `Game` node wires them together
9with `connect()`. That decoupling is the whole point of signals.
10
11# /// simvx
12# tags = ["tutorial", "beginner", "signals", "nodes"]
13# web = { root = "Game", width = 800, height = 600, responsive = true }
14# ///
15
16## What you will learn
17
18- **Scene tree** -- `add_child()` builds a hierarchy; every node gets lifecycle hooks.
19- **Signal** -- declare `Signal()` attributes; `emit(...)` to announce, `connect(fn)` to listen.
20- **Decoupling** -- the emitter knows nothing about its listeners; the parent wires them.
21- **Lifecycle** -- `on_ready()` to set up, `on_update(dt)` for per-frame logic.
22
23## How it works
24
25`Game.on_ready()` adds a `Player` and a `HealthBar` as children, then connects the
26player's `health_changed` and `died` signals to handlers. `Game.on_update()` damages
27the player once a second. Each `take_damage()` emits `health_changed`, which the bar
28turns into a new fill width; at zero health the player emits `died` and the game
29respawns it. Add a second `HealthBar` and it just works: signals fan out to every
30listener.
31
32Run: uv run python examples/tutorials/nodes_and_signals/main.py
33Headless self-check: uv run python examples/tutorials/nodes_and_signals/main.py --test
34"""
35
36from simvx.core import Node2D, Signal, Vec2
37from simvx.graphics import App
38
39WIDTH, HEIGHT = 800, 600
40PLAYER_RADIUS = 60
41
42
43class Player(Node2D):
44 """Owns its health and announces changes. Knows nothing about who is listening."""
45
46 def on_ready(self):
47 self.max_health = 100
48 self.health = self.max_health
49 # Two signals: one carries the new health, one is a bare "it happened" event.
50 self.health_changed = Signal()
51 self.died = Signal()
52
53 def take_damage(self, amount: int):
54 self.health = max(0, self.health - amount)
55 self.health_changed.emit(self.health, self.max_health)
56 if self.health == 0:
57 self.died.emit()
58
59 def revive(self):
60 self.health = self.max_health
61 self.health_changed.emit(self.health, self.max_health)
62
63 def on_draw(self, renderer):
64 renderer.draw_circle(self.position, PLAYER_RADIUS, colour=(0.4, 0.8, 1.0, 1.0), filled=True)
65 # Hand the text layout the circle's box and let it do the centring,
66 # rather than nudging the label by hand-measured pixels.
67 box = (self.position.x - PLAYER_RADIUS, self.position.y - PLAYER_RADIUS, PLAYER_RADIUS * 2, PLAYER_RADIUS * 2)
68 renderer.draw_text(
69 "PLAYER", rect=box, scale=1.5, colour=(0, 0, 0), alignment="centre", vertical_alignment="centre"
70 )
71
72
73class HealthBar(Node2D):
74 """Draws a bar. Updated only through the signal it is connected to."""
75
76 def on_ready(self):
77 self._ratio = 1.0
78
79 def on_health_changed(self, current: int, maximum: int):
80 self._ratio = current / maximum
81 # ``_ratio`` is plain state, not a Property, so the retained 2D pipeline
82 # cannot see it changed: ask for a redraw so on_draw re-runs with it.
83 self.queue_redraw()
84
85 def on_draw(self, renderer):
86 x, y, w, h = self.position.x, self.position.y, 300, 28
87 renderer.draw_rect((x, y), (w, h), colour=(0.15, 0.15, 0.15, 1.0), filled=True)
88 fill = (0.3, 0.85, 0.4, 1.0) if self._ratio > 0.3 else (0.9, 0.3, 0.3, 1.0)
89 renderer.draw_rect((x, y), (w * self._ratio, h), colour=fill, filled=True)
90 renderer.draw_text(f"{int(self._ratio * 100)}%", (x + w + 12, y + 4), scale=1.5, colour=(1, 1, 1))
91
92
93class Game(Node2D):
94 """Root node: builds the tree and wires the signals together."""
95
96 def on_ready(self):
97 self.player = self.add_child(Player(position=Vec2(WIDTH / 2, HEIGHT / 2)))
98 self.bar = self.add_child(HealthBar(position=Vec2(WIDTH / 2 - 150, 60)))
99
100 # Wire emitter -> listener. The Player and HealthBar never reference each other.
101 self.player.health_changed.connect(self.bar.on_health_changed)
102 self.player.died.connect(self._on_player_died)
103
104 self._elapsed = 0.0
105
106 def on_update(self, dt: float):
107 # Drive the demo: chip away one bite of health per second.
108 self._elapsed += dt
109 if self._elapsed >= 1.0:
110 self._elapsed = 0.0
111 self.player.take_damage(10)
112
113 def _on_player_died(self):
114 self.player.revive()
115
116 def on_draw(self, renderer):
117 renderer.draw_text("Nodes and Signals", (20, 20), scale=2, colour=(1, 1, 1))
118 renderer.draw_text("health drains 10/sec, revives at 0", (20, 52), scale=1, colour=(0.7, 0.7, 0.7))
119
120
121def _selftest() -> bool:
122 """Headless: watch the health drain and check every claim the lesson makes.
123
124 The bar is never told anything directly: its fill ratio is read back after the
125 player has emitted, which is the whole decoupling claim. A second HealthBar is
126 connected mid-run to check that a signal fans out to every listener, and the
127 ``died`` handler is checked by the health it leaves behind rather than by
128 counting emissions.
129 """
130 from simvx.graphics.testing import assert_not_blank, save_png
131
132 SECOND_BAR = 5 # a listener added after the wiring is already running
133 TOTAL = 13 * 60 + 5 # past the tenth hit, so the player dies and revives
134
135 app = App(title="Nodes and Signals", width=WIDTH, height=HEIGHT, visible=False)
136 scene = Game(name="Game")
137 seen: dict[str, object] = {}
138 ratios: list[tuple[int, float, float]] = [] # (health, primary bar ratio, second bar ratio)
139
140 announced: list[int] = []
141 deaths: list[int] = []
142
143 def on_frame(idx: int, _t: float) -> bool:
144 if idx == 1:
145 seen["start"] = (scene.player.health, scene.bar._ratio)
146 # Listen in on the same signals the bar is wired to, to see every
147 # value announced -- including the ones the revive passes straight
148 # through in the frame it happens.
149 scene.player.health_changed.connect(lambda current, _max: announced.append(current))
150 scene.player.died.connect(lambda: deaths.append(len(announced)))
151 elif idx == SECOND_BAR:
152 second = scene.add_child(HealthBar(position=Vec2(WIDTH / 2 - 150, 110)))
153 scene.player.health_changed.connect(second.on_health_changed)
154 seen["second"] = second
155 health = scene.player.health
156 if not ratios or ratios[-1][0] != health:
157 second = seen.get("second")
158 ratios.append((health, float(scene.bar._ratio), float(second._ratio) if second else -1.0))
159 return True
160
161 frames = app.run_headless(scene, frames=TOTAL, on_frame=on_frame, capture_frames=[TOTAL - 1])
162 assert_not_blank(frames[0])
163 save_png(frames[0], "/tmp/nodes_and_signals_test.png")
164
165 ok = True
166
167 def check(label: str, passed: bool, detail: str) -> None:
168 nonlocal ok
169 ok = ok and passed
170 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
171
172 check(
173 "the tree is built in on_ready, with the player and the bar as children",
174 scene.player in scene.children and scene.bar in scene.children and seen["start"] == (100, 1.0),
175 f"player and bar are children, health {seen['start'][0]} at ratio {seen['start'][1]:.2f}",
176 )
177
178 check(
179 "health drains ten points a second, and every step is announced",
180 announced[:10] == list(range(90, -1, -10)),
181 " -> ".join(str(h) for h in announced[:10]),
182 )
183 check(
184 "the bar follows the signal without the player ever touching it",
185 all(abs(ratio - health / 100) < 1e-6 for health, ratio, _ in ratios),
186 f"every one of the {len(ratios)} health values had the bar at the matching fraction",
187 )
188 zero = announced.index(0)
189 check(
190 "reaching zero emits died, and its handler revives to full in the same frame",
191 bool(deaths) and announced[zero - 1] == 10 and announced[zero + 1] == 100,
192 f"announced {announced[zero - 1]} -> {announced[zero]} -> {announced[zero + 1]}, with died fired in between",
193 )
194 fanned = [(health, other) for health, _, other in ratios if other >= 0.0]
195 check(
196 "a second HealthBar connected later receives the same signal, unchanged",
197 bool(fanned) and all(abs(other - health / 100) < 1e-6 for health, other in fanned),
198 f"the added bar tracked all {len(fanned)} of the changes it was connected for",
199 )
200
201 print("screenshot: /tmp/nodes_and_signals_test.png")
202 print("SELFTEST:", "PASS" if ok else "FAIL")
203 return ok
204
205
206if __name__ == "__main__":
207 import sys
208
209 if "--test" in sys.argv:
210 sys.exit(0 if _selftest() else 1)
211 App(title="Nodes and Signals", width=WIDTH, height=HEIGHT).run(Game())