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 |
|
Each |
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¶
docs/core/nodes.md: full Node lifecycle and child API.docs/core/input.md:InputMap.add_actionand theon_readyrule that survives web export.
- already-composed example:
Paddle,Ball,PongGame, pairedBall.scoredsignal.