From monolithic to composed

How to turn a game that lives in one node into small nodes that talk through signals, and why it pays off. The game is Dodge: slide a paddle along the bottom and avoid the falling blocks. Get the finished, composed version and run it:

simvx examples copy tutorials_monolith_to_composed --to dodge
python dodge/main.py

From a source checkout, prefix commands with uv run. Read the whole file on the source host.

Why compose

One node does everything

Small nodes and signals

One class, quick to start

A few classes, a little more typing

Every behaviour can read every other’s state

Each node owns its own state

on_update grows into one long method

Each on_update is short

A test has to build the whole game

Each node can be tested on its own

The editor shows one big node

The scene tree shows the real structure

The starting point: one node

This version works. It is a sketch to compare against, not a shipped example:

import random

from simvx.core import Axis1D, Controller, Input, Key, Node2D, Pointer, Vec2
from simvx.graphics import App

PLAYER_SIZE = Vec2(80, 16)
ENEMY_SIZE = Vec2(40, 40)


class DodgeGame(Node2D):
    dynamic = True  # on_draw reads plain fields that change every frame
    input_context = {
        "move": [Axis1D(low=Key.A, high=Key.D), Axis1D(low=Key.LEFT, high=Key.RIGHT), Controller.LEFT_X],
        "restart": [Key.ENTER, Pointer.PRIMARY],
    }

    def on_ready(self):
        self.player_x = self.viewport_size.x / 2
        self.enemies = []  # [x, y, speed] for each falling block
        self.spawn_in = 0.0
        self.score = 0
        self.alive = True

    def on_update(self, dt: float):
        width, height = self.viewport_size
        if not self.alive:
            if Input.just_pressed("restart"):
                self.on_ready()
            return
        half = PLAYER_SIZE.x / 2
        self.player_x = max(half, min(width - half, self.player_x + Input.value("move") * 360 * dt))
        self.spawn_in -= dt
        if self.spawn_in <= 0:
            self.spawn_in = random.uniform(0.4, 0.9)
            self.enemies.append([random.uniform(20, width - 20), -ENEMY_SIZE.y, random.uniform(150, 300)])
        for enemy in self.enemies:
            enemy[1] += enemy[2] * dt
        escaped = [e for e in self.enemies if e[1] > height + ENEMY_SIZE.y]
        self.score += len(escaped)
        self.enemies = [e for e in self.enemies if e not in escaped]
        reach = (PLAYER_SIZE + ENEMY_SIZE) / 2
        player_y = height - 60
        for x, y, _ in self.enemies:
            if abs(x - self.player_x) < reach.x and abs(y - player_y) < reach.y:
                self.alive = False

    def on_draw(self, renderer):
        width, height = self.viewport_size
        renderer.rect((0, 0), (width, height), fill=(0.05, 0.05, 0.08, 1))
        renderer.rect((self.player_x, height - 60) - PLAYER_SIZE / 2, PLAYER_SIZE, fill=(0.5, 0.9, 1.0, 1))
        for x, y, _ in self.enemies:
            renderer.rect((x, y) - ENEMY_SIZE / 2, ENEMY_SIZE, fill=(1.0, 0.4, 0.3, 1))
        renderer.text(f"Score: {self.score}", (20, 20), fill=(1, 1, 1, 1), scale=2)
        if not self.alive:
            renderer.text(
                "Game Over: Enter or tap to restart", (width / 2, height / 2), fill=(1, 1, 0.3, 1), scale=2, align="centre"
            )


if __name__ == "__main__":
    App(title="Dodge", width=800, height=600).run(DodgeGame())

DodgeGame is a player controller, a spawner, an enemy fleet, a collision check, a score counter and a HUD in one class. Its state is plain fields that change every frame, so it needs dynamic = True to redraw at all. The composed version below drops it: each node draws from a Property or its own transform, and those redraw by themselves.

Step 1: the player

The player has one piece of state, its position, and one job: move and stay on screen.

class Player(Node2D):
    """Moves left and right, from the `move` action or toward a held pointer."""

    speed = Property(360.0, range=(50, 800))

    def on_update(self, dt: float):
        dx = Input.value("move")
        if Input.pressed("steer"):
            dx = max(-1.0, min(1.0, (Input.mouse_position.x - self.position.x) / 32.0))
        half = PLAYER_SIZE.x / 2
        x = max(half, min(self.viewport_size.x - half, self.position.x + dx * self.speed * dt))
        self.position = (x, self.position.y)

    def on_draw(self, renderer):
        renderer.rect(-PLAYER_SIZE / 2, PLAYER_SIZE, fill=(0.5, 0.9, 1.0, 1))

speed is a Property, so the editor shows it, and the paddle draws itself around its own position. steer is the held pointer, so a finger on a phone moves the paddle too.

Step 2: the enemy and the spawner

An enemy falls, and when it leaves the bottom it calls its escaped signal and removes itself:

class Enemy(Node2D):
    """Falls straight down and announces when it leaves the bottom of the screen."""

    speed = Property(220.0, range=(50, 600))

    escaped = Signal()

    def on_update(self, dt: float):
        self.position += (0, self.speed * dt)
        if self.position.y > self.viewport_size.y + ENEMY_SIZE.y:
            self.escaped.emit()
            self.destroy()

    def on_draw(self, renderer):
        renderer.rect(-ENEMY_SIZE / 2, ENEMY_SIZE, fill=(1.0, 0.4, 0.3, 1))

The spawner draws nothing. A repeating Timer calls spawn, which emits a new enemy through spawned:

class Spawner(Node):
    """Draws nothing: after each random delay it emits a new Enemy for the root to place."""

    min_delay = Property(0.4)
    max_delay = Property(0.9)

    spawned = Signal(Enemy)

    def on_ready(self):
        self.timer = self.add_child(Timer(duration=self.min_delay, one_shot=False, autostart=True))
        self.timer.timeout.connect(self.spawn)

    def spawn(self):
        x = random.uniform(20, self.viewport_size.x - 20)
        self.spawned.emit(Enemy(speed=random.uniform(150, 300), position=(x, -ENEMY_SIZE.y)))
        self.timer.duration = random.uniform(self.min_delay, self.max_delay)

The spawner does not decide where the enemy goes in the tree. Whoever listens to spawned does.

Step 3: the score

class ScoreLabel(Node2D):
    """Holds the score and draws it; announces each change."""

    score = Property(0)

    changed = Signal(int)

    def add(self, points: int = 1):
        self.score += points
        self.changed.emit(self.score)

    def on_draw(self, renderer):
        renderer.text(f"Score: {self.score}", (20, 20), fill=(1, 1, 1, 1), scale=2)

Step 4: the root wires the graph

The root owns no per-entity state. It decides which nodes exist, which signals connect to which methods, and the one thing only it can see: the player hitting an enemy.

class DodgeGame(Node2D):
    """Builds each round, wires its signals, and checks the one collision only it can see."""

    # Keeps ticking while the tree is paused, so it can hear the restart.
    update_mode = Property(UpdateMode.ALWAYS)
    game_over = Property(False)

    # `steer` and `restart` share the primary pointer, so a finger both plays and starts again.
    input_context = {
        "move": [Axis1D(low=Key.A, high=Key.D), Axis1D(low=Key.LEFT, high=Key.RIGHT), Controller.LEFT_X],
        "steer": [Pointer.PRIMARY],
        "restart": [Key.ENTER, Pointer.PRIMARY],
    }

    def on_ready(self):
        self.start_round()

    def start_round(self):
        self.game_over = False
        self.tree.paused = False
        # Everything that plays lives under one node, so one flag freezes it all
        # and one destroy() tears the round down.
        self.world = self.add_child(Node2D(name="World", update_mode=UpdateMode.PAUSABLE))
        width, height = self.viewport_size
        self.player = self.world.add_child(Player(position=(width / 2, height - 60)))
        self.score = self.world.add_child(ScoreLabel())
        self.spawner = self.world.add_child(Spawner())
        self.spawner.spawned.connect(self.place_enemy)
        self.score.changed.connect(self.ramp_difficulty)

    def place_enemy(self, enemy: Enemy):
        enemy.escaped.connect(self.score.add)
        self.world.add_child(enemy)

    def ramp_difficulty(self, score: int):
        self.spawner.min_delay = max(0.12, 0.4 - score * 0.01)
        self.spawner.max_delay = max(0.3, 0.9 - score * 0.02)

    def on_update(self, dt: float):
        if self.game_over:
            if Input.just_pressed("restart"):
                self.world.destroy()
                self.start_round()
            return
        reach = (PLAYER_SIZE + ENEMY_SIZE) / 2
        for enemy in self.world.find_all(Enemy, direct=True):
            gap = abs(enemy.position - self.player.position)
            if gap.x < reach.x and gap.y < reach.y:
                self.game_over = True
                self.tree.paused = True
                return

    def on_draw(self, renderer):
        width, height = self.viewport_size
        renderer.rect((0, 0), (width, height), fill=(0.05, 0.05, 0.08, 1))
        if self.game_over:
            renderer.text(
                "Game Over: Enter or tap to restart",
                (width / 2, height / 2),
                fill=(1, 1, 0.3, 1),
                scale=2,
                align="centre",
            )
  • Every gameplay node lives under one World node, so tree.paused = True freezes the whole round at once and world.destroy() tears it down.

  • The root has update_mode = Property(UpdateMode.ALWAYS), so it keeps running through the pause and hears the restart. World is built with update_mode=UpdateMode.PAUSABLE, so it does not inherit that.

  • place_enemy connects each new enemy’s escaped to ScoreLabel.add, and ScoreLabel.changed reaches ramp_difficulty, which shortens the spawn delay as the score rises.

The signal-pairing rule

Every signal has an emitter and a listener. A signal nothing connects to is not an API, it is noise: delete it. When you reach for a global or a parent’s attribute, ask first whether the node that owns the data can emit a signal and the node that needs it can connect.

Testing payoff

A composed node can be tested on its own. This test builds one Enemy, with no game around it:

from simvx.core.testing import SceneRunner

from main import Enemy


def test_an_enemy_announces_its_escape_once():
    escapes = []
    enemy = Enemy(speed=600, position=(400, 590))
    enemy.escaped.connect(lambda: escapes.append(enemy))
    SceneRunner().load(enemy).advance_frames(10)
    assert escapes == [enemy]

In the monolith, every test is a test of the whole game.

When not to compose

Split when state has more than one owner, when a behaviour needs testing on its own, or when a later feature (a replay, an AI player) will need to hear events. A 30-line title screen or a debug overlay can stay one node.

See also