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_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 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
 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())