From Monolithic to Composed

How to refactor a single-file pygame-style game into the SimVX node-and-signal idiom, and why doing it pays off.

This tutorial uses Dodge the Box, a one-screen game where the player avoids falling enemies. We start with a working but monolithic version (one Node2D that owns everything) and end with a composed version (player, enemies, score widget, game loop: each its own node, talking via signals). The diff is small, but the design is night and day.

The finished composed version is the runnable Monolith to Composed tutorial (examples/tutorials/monolith_to_composed/, playable in the gallery):

uv run python examples/tutorials/monolith_to_composed/main.py

Why this matters

SimVX is happy to run a single-node game. You can keep player input, enemy spawning, collision, score counting, and HUD drawing inside one on_update and one on_draw. Roughly two-thirds of community ports do.

The trade-offs:

Monolithic / “god node”

Composed nodes + signals

One file, easy to start

Multiple files, slightly more typing

Every behaviour reads every other

Each node owns its own state

on_update becomes a mega-function

Each on_update is short

Tests have to spin up the whole game

Test each node in isolation

Editor inspector shows one big node

Inspector reveals real structure

Web export and tests both touch everything

Touching the HUD doesn’t touch combat

Signals are the thing that makes composition cheap. A node emits a fact (“the player died”, “an enemy was destroyed”), and any number of other nodes react. Nothing is hard-coded against any other node, and you can unit-test the emitter without instantiating any of the listeners.

The starting point: one node does everything

"""Dodge the Box: monolithic version."""

import random

from simvx.core import Input, InputMap, Key, Node2D, Vec2
from simvx.graphics import App

WIDTH, HEIGHT = 480, 640
PLAYER_W, PLAYER_H = 32, 32
ENEMY_W, ENEMY_H = 28, 28


class DodgeGame(Node2D):
    # The 2D renderer is retained, it re-runs on_draw only for dirty nodes. This god
    # node draws everything (player, enemies, score, HUD) from plain fields that change
    # every frame, none of them Properties, so it opts into a per-frame redraw. The
    # composed version below mostly sheds this: each node draws its own Property or
    # transform and auto-dirties.
    dynamic = True

    def on_ready(self):
        InputMap.add_action("left", [Key.A, Key.LEFT])
        InputMap.add_action("right", [Key.D, Key.RIGHT])
        InputMap.add_action("restart", [Key.ENTER])

        self.player_x = WIDTH / 2
        self.enemies: list[list[float]] = []   # [x, y, vy]
        self.spawn_timer = 0.0
        self.score = 0
        self.alive = True

    def on_update(self, dt: float):
        if not self.alive:
            if Input.is_action_just_pressed("restart"):
                self.on_ready()
            return

        # Player movement
        dx = Input.get_strength("right") - Input.get_strength("left")
        self.player_x = max(PLAYER_W / 2, min(WIDTH - PLAYER_W / 2, self.player_x + dx * 320 * dt))

        # Enemy spawn
        self.spawn_timer -= dt
        if self.spawn_timer <= 0:
            self.spawn_timer = random.uniform(0.4, 0.9)
            self.enemies.append([random.uniform(0, WIDTH), -ENEMY_H, random.uniform(120, 260)])

        # Move + cull enemies + collide
        py = HEIGHT - 60
        for e in self.enemies:
            e[1] += e[2] * dt
        self.enemies = [e for e in self.enemies if e[1] < HEIGHT + ENEMY_H]
        for e in self.enemies:
            if abs(e[0] - self.player_x) < (PLAYER_W + ENEMY_W) / 2 and abs(e[1] - py) < (PLAYER_H + ENEMY_H) / 2:
                self.alive = False
                return

        self.score += int(60 * dt)

    def on_draw(self, renderer):
        renderer.draw_rect((0, 0), (WIDTH, HEIGHT), colour=(0.05, 0.05, 0.08, 1), filled=True)
        py = HEIGHT - 60
        renderer.draw_rect((self.player_x - PLAYER_W / 2, py - PLAYER_H / 2), (PLAYER_W, PLAYER_H),
                           colour=(0.5, 0.9, 1.0, 1), filled=True)
        for e in self.enemies:
            renderer.draw_rect((e[0] - ENEMY_W / 2, e[1] - ENEMY_H / 2), (ENEMY_W, ENEMY_H),
                               colour=(1.0, 0.4, 0.3, 1), filled=True)
        renderer.draw_text(f"Score: {self.score}", (10, 10), colour=(1, 1, 1, 1))
        if not self.alive:
            renderer.draw_text("Game Over: Enter to restart", (WIDTH / 2 - 120, HEIGHT / 2), colour=(1, 1, 0.3, 1))


App(title="Dodge", width=WIDTH, height=HEIGHT).run(DodgeGame())

About 75 lines. It works. Now look at what DodgeGame is: a player controller, a spawner, an enemy fleet, a collision system, a score counter, and a HUD: all in one class. The game-over flow tangles input handling with state reset and with rendering.

Step 1: extract the Player

The player has clear state (its x), one input concern (left/right), and one behaviour (clamp to bounds). Pull it into its own Node2D:

class Player(Node2D):
    speed = Property(320.0, range=(50, 800))

    def on_update(self, dt: float):
        dx = Input.get_strength("right") - Input.get_strength("left")
        new_x = self.position.x + dx * float(self.speed) * dt
        self.position = Vec2(max(PLAYER_W / 2, min(WIDTH - PLAYER_W / 2, new_x)), self.position.y)

    def on_draw(self, renderer):
        x = self.position.x - PLAYER_W / 2
        y = self.position.y - PLAYER_H / 2
        renderer.draw_rect((x, y), (PLAYER_W, PLAYER_H), colour=(0.5, 0.9, 1.0, 1), filled=True)

The root scene now adds a child instead of tracking a raw x value:

self.player = self.add_child(Player(position=Vec2(WIDTH / 2, HEIGHT - 60)))

Already a win: speed is editor-visible because it’s a Property, the draw uses the engine’s position, and there’s nothing else for Player to interfere with.

Step 2: extract the Enemy + Spawner

An enemy is a Node2D with a single field (speed), a one-line move, and an off-screen self-destruct. It emits a signal when it leaves the screen so the scorer can count it.

class Enemy(Node2D):
    speed = Property(200.0, range=(50, 600))

    def __init__(self, speed_value: float, **kwargs):
        super().__init__(**kwargs)
        self.speed = speed_value
        self.escaped = Signal()   # emitted when we leave the bottom

    def on_update(self, dt: float):
        self.position = Vec2(self.position.x, self.position.y + float(self.speed) * dt)
        if self.position.y > HEIGHT + ENEMY_H:
            self.escaped.emit()
            self.destroy()

    def on_draw(self, renderer):
        x = self.position.x - ENEMY_W / 2
        y = self.position.y - ENEMY_H / 2
        renderer.draw_rect((x, y), (ENEMY_W, ENEMY_H), colour=(1.0, 0.4, 0.3, 1), filled=True)

The spawner is a tiny node: no draw, just a timer.

class Spawner(Node2D):
    min_delay = Property(0.4)
    max_delay = Property(0.9)
    enemy_speed_range = Property((120.0, 260.0))

    def on_ready(self):
        self._timer = 0.0
        self.spawned = Signal()   # emits the new Enemy node

    def on_update(self, dt: float):
        self._timer -= dt
        if self._timer <= 0:
            self._timer = random.uniform(float(self.min_delay), float(self.max_delay))
            lo, hi = self.enemy_speed_range
            enemy = Enemy(random.uniform(lo, hi), position=Vec2(random.uniform(0, WIDTH), -ENEMY_H))
            self.spawned.emit(enemy)

Note the paired signal: Spawner.spawned lets the game root parent the new enemy wherever it likes: under a “world” layer for ordering, under the HUD’s parent, or as a sibling of the player. The spawner itself doesn’t know. That’s the decoupling signals buy you.

Step 3: extract the ScoreLabel

Score is its own concern, with its own rendering. It’s a node that listens for spawned (count enemies dodged after they escape):

class ScoreLabel(Node2D):
    score = Property(0)

    def on_ready(self):
        self.changed = Signal()

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

    def on_draw(self, renderer):
        renderer.draw_text(f"Score: {int(self.score)}", (10, 10), colour=(1, 1, 1, 1))

Step 4: wire it all up in the root

The root is now small. It owns nothing but the graph: which nodes exist, and which signals connect to which slots.

class DodgeGame(Node2D):
    def on_ready(self):
        InputMap.add_action("left", [Key.A, Key.LEFT])
        InputMap.add_action("right", [Key.D, Key.RIGHT])
        InputMap.add_action("restart", [Key.ENTER])

        self.alive = True
        self.player = self.add_child(Player(position=Vec2(WIDTH / 2, HEIGHT - 60)))
        self.spawner = self.add_child(Spawner())
        self.score = self.add_child(ScoreLabel())
        self.died = Signal()

        self.spawner.spawned.connect(self._on_spawned)

    def _on_spawned(self, enemy: Enemy):
        enemy.escaped.connect(lambda: self.score.add(1))
        self.add_child(enemy)

    def on_update(self, dt: float):
        if not self.alive:
            if Input.is_action_just_pressed("restart"):
                self.died.emit()       # tell the world; HUD listens
                self.clear_children()
                self.on_ready()
                self.queue_redraw()    # back to alive: dirty so the "Game Over" text clears
            return

        # Per-frame collision: only the root knows about the player and the enemies.
        px, py = self.player.position
        for child in list(self.children):
            if isinstance(child, Enemy):
                if abs(child.position.x - px) < (PLAYER_W + ENEMY_W) / 2 \
                   and abs(child.position.y - py) < (PLAYER_H + ENEMY_H) / 2:
                    self.alive = False
                    self.died.emit()
                    self.queue_redraw()  # `alive` isn't a Property: dirty so "Game Over" shows
                    return

    def on_draw(self, renderer):
        renderer.draw_rect((0, 0), (WIDTH, HEIGHT), colour=(0.05, 0.05, 0.08, 1), filled=True)
        if not self.alive:
            renderer.draw_text("Game Over: Enter to restart",
                               (WIDTH / 2 - 120, HEIGHT / 2), colour=(1, 1, 0.3, 1))

Compare to the monolithic on_update: this one is 12 lines of game flow (alive check, restart, collision sweep). All the per-entity behaviour lives where it belongs.

The signal-pairing rule

Every Signal exists for two purposes: someone emits it, and someone connects to it. If your refactor introduces a signal that nothing connects to, delete it: it’s vestigial documentation, not a contract. (The Snake / Tetris ports defined died and ate signals that no consumer ever listened to. They look like API surface; they’re not. Dead signals are confusing.)

When you find yourself reaching for a global, a class attribute, or a parent attribute, ask first: can the node that owns the data emit a signal, and can the node that needs the data connect to it?

Testing payoff

Before:

def test_player_clamps_to_bounds():
    game = DodgeGame()
    game.on_ready()
    # Spin up the entire game (spawner, score, HUD) just to test the player.

After:

def test_player_clamps_to_bounds():
    player = Player(position=Vec2(0, 0))
    runner = SceneRunner()
    runner.load(player)
    InputSimulator().press_key("left")
    runner.advance_frames(60)
    assert player.position.x >= PLAYER_W / 2     # clamped, didn't go negative

The composed version makes every node a unit. The monolithic version forced every test to be an integration test.

When not to compose

Don’t fracture for the sake of fracturing. If a behaviour is genuinely one piece (a 30-line title screen, a debug-overlay drawer, a one-shot splash), keep it in one node. Compose when state grows multiple owners, when behaviours need to be tested in isolation, or when a future feature (replay, AI, networking) will need to subscribe to events the monolith doesn’t expose.

See also

  • docs/core/nodes.md: full Node lifecycle and child API.

  • docs/core/input.md: InputMap.add_action and the on_ready rule that survives web export.

  • examples/tutorials/pong/main.py: already-composed example: Paddle, Ball, PongGame, paired Ball.scored signal.