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 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 its children in
on_ready():
class Game(Node2D):
def on_ready(self):
self.player = self.add_child(Player())
self.bar = self.add_child(HealthBar())
add_child() returns the child, so you can keep a handle to it. Every child
gets its own lifecycle hooks, such as on_ready() and on_draw(renderer).
Step 2: Declare a signal¶
A signal is an event a node can announce. Declare it as a class attribute, typed with what it carries, and emit it by calling it:
class Player(Node2D):
health_changed = Signal(int, int) # (current, maximum)
died = Signal() # a bare "it happened" event
max_health = 100
health = 100
def take_damage(self, amount):
self.health = max(0, self.health - amount)
self.health_changed(self.health, self.max_health)
if self.health == 0:
self.died()
Each Player gets its own copy of each signal, so listeners on one player never
hear another. 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):
ratio = Property(1.0, range=(0.0, 1.0))
width = Property(300.0)
def on_health_changed(self, current, maximum):
self.ratio = current / maximum
The 2D renderer keeps what on_draw() drew and re-runs it only when the node
changes. ratio is a Property, so setting it repaints the bar by itself. A
node that draws from a plain attribute has to call queue_redraw() after
changing it instead.
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.player.revive)
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 with a Timer¶
Engine nodes announce things the same way. A repeating Timer emits timeout
every second, and the game connects it to a hit:
damage = self.add_child(Timer(duration=1.0, one_shot=False, autostart=True))
damage.timeout.connect(lambda: self.player.take_damage(10))
The bar drains by a tenth each second, and when died fires the player revives
to full.
Step 5: The tree is an emitter too¶
self.viewport_size is the live size of whatever this node draws into, and the
tree emits screen_resized when it changes, so the layout is one more listener
rather than something that polls:
def on_ready(self):
...
self.tree.screen_resized.connect(self.layout)
self.layout()
def layout(self, _size=None):
width, height = self.viewport_size
self.player.position = (width / 2, height / 2)
self.bar.width = min(300.0, width * 0.4)
self.bar.position = (width / 2 - self.bar.width / 2, 90)
A narrow window gets a narrower bar instead of one that runs off the edge with
its percentage label, and because width is a Property the bar repaints at
its new size. The window size appears once, in App(width=800, height=600),
as the window to ask for; the scene measures its own.
Run it¶
# In your own copy of this directory
python main.py
# Anywhere, with the example library installed
simvx examples run tutorials_nodes_and_signals
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# /// simvx
2# tags = ["tutorial", "beginner", "signals", "nodes"]
3# web = { width = 800, height = 600, responsive = true }
4# ///
5"""Nodes and Signals: build a scene tree and wire nodes together with signals.
6
7A game is many nodes reacting to each other: a health bar shrinks when the player
8is hit. Signals let them do that without knowing about one another.
9
10**The scene tree.** `add_child()` puts a node under one already in the tree and
11returns it, so the root `Game` keeps a handle on its `Player`, `HealthBar` and
12`Timer`. Every node gets its own lifecycle hooks: `on_ready()` once it is in the
13tree, `on_draw(renderer)` to paint itself.
14
15**Declaring and emitting.** A signal is a class attribute, typed with what it
16carries, and emitting is calling it:
17
18```python
19class Player(Node2D):
20 health_changed = Signal(int, int) # (current, maximum)
21 died = Signal()
22
23 def take_damage(self, amount):
24 self.health = max(0, self.health - amount)
25 self.health_changed(self.health, self.max_health)
26 if self.health == 0:
27 self.died()
28```
29
30Each `Player` gets its own copy of the signal, and it never learns who listens.
31
32**Connecting.** The parent wires emitter to listener with `connect()`, so the
33`Player` and the `HealthBar` never reference each other:
34
35```python
36self.player.health_changed.connect(self.bar.on_health_changed)
37self.player.died.connect(self.player.revive)
38```
39
40One emit calls every connected listener, so a second `HealthBar` connected the
41same way follows the same player with no change to `Player`.
42
43**Engine nodes speak the same language.** A repeating `Timer` emits `timeout`
44once a second and the game connects it to a hit of 10 damage, so the bar drains
45by a tenth each second and refills when `died` revives the player. The tree
46emits `screen_resized` when the window changes size, and the layout is one more
47listener: it centres the player on `self.viewport_size` and sizes the bar to at
48most 40 percent of the width, so the scene fits a phone as well as the 800 by
49600 window it opens at.
50
51**Redrawing from a Property.** The 2D renderer keeps what `on_draw` drew and
52re-runs it only when the node changes. `HealthBar` draws from `ratio` and
53`width`, which are `Property` descriptors, so setting either repaints the bar
54by itself. A node that draws from a plain attribute calls `queue_redraw()`
55after changing it instead.
56"""
57
58from simvx.core import Node2D, Property, Signal, Timer
59from simvx.graphics import App
60
61PLAYER_RADIUS = 60
62BAR_HEIGHT = 28
63
64
65class Player(Node2D):
66 """Owns its health and announces changes. Knows nothing about who is listening."""
67
68 health_changed = Signal(int, int)
69 died = Signal()
70 max_health = 100
71 health = 100
72
73 def take_damage(self, amount: int):
74 self.health = max(0, self.health - amount)
75 self.health_changed(self.health, self.max_health)
76 if self.health == 0:
77 self.died()
78
79 def revive(self):
80 self.health = self.max_health
81 self.health_changed(self.health, self.max_health)
82
83 def on_draw(self, renderer):
84 renderer.circle((0, 0), PLAYER_RADIUS, fill=(0.4, 0.8, 1.0, 1.0))
85 box = (-PLAYER_RADIUS, -PLAYER_RADIUS, PLAYER_RADIUS * 2, PLAYER_RADIUS * 2)
86 renderer.text("PLAYER", fill=(0, 0, 0), scale=1.5, align="centre", valign="centre", rect=box)
87
88
89class HealthBar(Node2D):
90 """Draws a bar. Updated only through the signal it is connected to."""
91
92 ratio = Property(1.0, range=(0.0, 1.0))
93 width = Property(300.0)
94
95 def on_health_changed(self, current: int, maximum: int):
96 self.ratio = current / maximum
97
98 def on_draw(self, renderer):
99 renderer.rect((0, 0), (self.width, BAR_HEIGHT), fill=(0.15, 0.15, 0.15, 1.0))
100 fill = (0.3, 0.85, 0.4, 1.0) if self.ratio > 0.3 else (0.9, 0.3, 0.3, 1.0)
101 renderer.rect((0, 0), (self.width * self.ratio, BAR_HEIGHT), fill=fill)
102 renderer.text(f"{round(self.ratio * 100)}%", (self.width + 12, 4), fill=(1, 1, 1), scale=1.5)
103
104
105class Game(Node2D):
106 """Root node: builds the tree and wires the signals together."""
107
108 def on_ready(self):
109 self.player = self.add_child(Player())
110 self.bar = self.add_child(HealthBar())
111 self.player.health_changed.connect(self.bar.on_health_changed)
112 self.player.died.connect(self.player.revive)
113
114 damage = self.add_child(Timer(duration=1.0, one_shot=False, autostart=True))
115 damage.timeout.connect(lambda: self.player.take_damage(10))
116
117 self.tree.screen_resized.connect(self.layout)
118 self.layout()
119
120 def layout(self, _size=None):
121 width, height = self.viewport_size
122 self.player.position = (width / 2, height / 2)
123 self.bar.width = min(300.0, width * 0.4)
124 self.bar.position = (width / 2 - self.bar.width / 2, 90)
125
126 def on_draw(self, renderer):
127 renderer.text("Nodes and Signals", (20, 20), fill=(1, 1, 1), scale=2)
128 renderer.text("health drains 10 a second, revives at 0", (20, 60), fill=(0.7, 0.7, 0.7), scale=1)
129
130
131if __name__ == "__main__":
132 App(title="Nodes and Signals", width=800, height=600).run(Game())