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.

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_process 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_process becomes a mega-function

Each on_process 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):
    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_process(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_process(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_process(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_process(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_process(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()
            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()
                    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_process: 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

already-composed example:

Paddle, Ball, PongGame, paired Ball.scored signal.