Nodes and Signals

build a scene tree and wire nodes together with signals.

▶ Run in browser

Tags: 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: Property descriptors 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())