# 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 ```python """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`: ```python 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: ```python 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. ```python 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. ```python 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): ```python 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. ```python 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: ```python 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: ```python 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`](../core/nodes.md): full Node lifecycle and child API. - [`docs/core/input.md`](../core/input.md): `InputMap.add_action` and the `on_ready` rule that survives web export. - [`packages/graphics/examples/game_pong.py`](https://fezzik.dev/fezzik/simvx/src/branch/main/packages/graphics/examples/game_pong.py) : already-composed example: `Paddle`, `Ball`, `PongGame`, paired `Ball.scored` signal.