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 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 BallsProperty descriptors 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())