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_health_changed(self, current, maximum):
self._ratio = current / maximum
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()
Run it¶
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"""
32
33from simvx.core import Node2D, Signal, Vec2
34from simvx.graphics import App
35
36WIDTH, HEIGHT = 800, 600
37
38
39class Player(Node2D):
40 """Owns its health and announces changes. Knows nothing about who is listening."""
41
42 def on_ready(self):
43 self.max_health = 100
44 self.health = self.max_health
45 # Two signals: one carries the new health, one is a bare "it happened" event.
46 self.health_changed = Signal()
47 self.died = Signal()
48
49 def take_damage(self, amount: int):
50 self.health = max(0, self.health - amount)
51 self.health_changed.emit(self.health, self.max_health)
52 if self.health == 0:
53 self.died.emit()
54
55 def revive(self):
56 self.health = self.max_health
57 self.health_changed.emit(self.health, self.max_health)
58
59 def on_draw(self, renderer):
60 renderer.draw_circle(self.position, 60, colour=(0.4, 0.8, 1.0, 1.0), filled=True)
61 renderer.draw_text("PLAYER", (self.position.x - 38, self.position.y - 7), scale=1.5, colour=(0, 0, 0))
62
63
64class HealthBar(Node2D):
65 """Draws a bar. Updated only through the signal it is connected to."""
66
67 def on_ready(self):
68 self._ratio = 1.0
69
70 def on_health_changed(self, current: int, maximum: int):
71 self._ratio = current / maximum
72
73 def on_draw(self, renderer):
74 x, y, w, h = self.position.x, self.position.y, 300, 28
75 renderer.draw_rect((x, y), (w, h), colour=(0.15, 0.15, 0.15, 1.0), filled=True)
76 fill = (0.3, 0.85, 0.4, 1.0) if self._ratio > 0.3 else (0.9, 0.3, 0.3, 1.0)
77 renderer.draw_rect((x, y), (w * self._ratio, h), colour=fill, filled=True)
78 renderer.draw_text(f"{int(self._ratio * 100)}%", (x + w + 12, y + 4), scale=1.5, colour=(1, 1, 1))
79
80
81class Game(Node2D):
82 """Root node: builds the tree and wires the signals together."""
83
84 def on_ready(self):
85 self.player = self.add_child(Player(position=Vec2(WIDTH / 2, HEIGHT / 2)))
86 self.bar = self.add_child(HealthBar(position=Vec2(WIDTH / 2 - 150, 60)))
87
88 # Wire emitter -> listener. The Player and HealthBar never reference each other.
89 self.player.health_changed.connect(self.bar.on_health_changed)
90 self.player.died.connect(self._on_player_died)
91
92 self._elapsed = 0.0
93
94 def on_update(self, dt: float):
95 # Drive the demo: chip away one bite of health per second.
96 self._elapsed += dt
97 if self._elapsed >= 1.0:
98 self._elapsed = 0.0
99 self.player.take_damage(10)
100
101 def _on_player_died(self):
102 self.player.revive()
103
104 def on_draw(self, renderer):
105 renderer.draw_text("Nodes and Signals", (20, 20), scale=2, colour=(1, 1, 1))
106 renderer.draw_text("health drains 10/sec, revives at 0", (20, 52), scale=1, colour=(0.7, 0.7, 0.7))
107
108
109if __name__ == "__main__":
110 App(title="Nodes and Signals", width=WIDTH, height=HEIGHT).run(Game())