Monolith to Composed

structure a game as small, single-purpose nodes.

▶ Run in browser

Tags: tutorial architecture signals

Monolith to Composed

You can build games now. This last tutorial is about building them well: how to structure a game so it stays easy to change. The game itself is tiny, a dodge game where you slide a paddle along the bottom to avoid falling blocks. The point is the shape of the code.

main.py is the composed version. The full before/after refactor, starting from one giant node that does everything, is the walkthrough in From Monolithic to Composed.

The idea: one node, one job

The tempting first draft is a single DodgeGame node that tracks the player’s x, runs a spawn timer, holds the score, draws everything, and checks collisions. It works, but every change touches the same 80-line method and nothing can be tested or reused in isolation.

The fix is to give each concern its own node:

node

its one job

Player

read input, move, clamp, draw itself

Enemy

fall, and announce when it escapes off the bottom

Spawner

a timer that emits each new enemy

ScoreLabel

hold the score and draw it

DodgeGame (root)

own the graph and the one thing only it can see: collision

Signals connect them

Each node stays ignorant of the others; the root wires them with signals:

self.spawner.spawned.connect(self._on_spawned)   # new enemy -> root places it

def _on_spawned(self, enemy):
    enemy.escaped.connect(lambda: self.score.add(1))   # dodged -> +1
    self.add_child(enemy)

The signal-pairing rule: every signal has an emitter and a listener. Here Spawner.spawned, Enemy.escaped, and ScoreLabel.changed are all paired. A signal with no listener (or a global reached for instead of a signal) is a smell that the structure is wrong.

The payoff

The root’s on_update shrinks to pure game flow (alive check, restart, collision sweep). Per-entity behaviour lives in the entity. Each node is small enough to test on its own with a SceneRunner, and reusable in the next game.

Run it

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

Source

  1"""Monolith to Composed: structure a game as small, single-purpose nodes.
  2
  3The capstone of the basics track. This is the *composed* version of a tiny dodge
  4game: move the paddle along the bottom with the arrow keys (or A/D) and avoid the
  5falling blocks; each block you dodge scores a point. The lesson is the structure,
  6not the game: instead of one giant node that does input, spawning, scoring, and
  7collision, every concern is its own node, and they talk through signals.
  8
  9Read the README for the before/after refactor; this file is the after.
 10
 11# /// simvx
 12# tags = ["tutorial", "architecture", "signals"]
 13# web = { root = "DodgeGame", width = 800, height = 600, responsive = true }
 14# ///
 15
 16## What you will learn
 17
 18- **One node, one job** -- `Player`, `Enemy`, `Spawner`, and `ScoreLabel` each own a
 19  single concern, so each is short and testable in isolation.
 20- **Paired signals** -- every signal here is emitted by one node and connected by
 21  another: `Spawner.spawned`, `Enemy.escaped`, `ScoreLabel.changed`. A signal with
 22  no listener (or a listener with no emitter) is a smell.
 23- **The root owns the graph, not the state** -- `DodgeGame` only decides which nodes
 24  exist and which signals wire to which slots, plus the one thing only it can see:
 25  player-versus-enemy collision.
 26"""
 27
 28import random
 29
 30from simvx.core import Input, Key, Node2D, Property, Signal, Vec2
 31from simvx.graphics import App
 32
 33WIDTH, HEIGHT = 800, 600
 34PLAYER_W, PLAYER_H = 80, 16
 35ENEMY_W, ENEMY_H = 40, 40
 36
 37
 38class Player(Node2D):
 39    """Owns its position and its one input concern: move left/right, clamped."""
 40
 41    speed = Property(360.0, range=(50, 800))
 42
 43    def on_update(self, dt: float):
 44        dx = Input.get_strength("right") - Input.get_strength("left")
 45        new_x = self.position.x + dx * float(self.speed) * dt
 46        self.position = Vec2(max(PLAYER_W / 2, min(WIDTH - PLAYER_W / 2, new_x)), self.position.y)
 47
 48    def on_draw(self, renderer):
 49        renderer.draw_rect((self.position.x - PLAYER_W / 2, self.position.y - PLAYER_H / 2),
 50                           (PLAYER_W, PLAYER_H), colour=(0.5, 0.9, 1.0, 1), filled=True)
 51
 52
 53class Enemy(Node2D):
 54    """Falls straight down and announces when it escapes off the bottom."""
 55
 56    def __init__(self, speed_value: float, **kwargs):
 57        super().__init__(**kwargs)
 58        self._speed = speed_value
 59        self.escaped = Signal()  # the player dodged it
 60
 61    def on_update(self, dt: float):
 62        self.position = Vec2(self.position.x, self.position.y + self._speed * dt)
 63        if self.position.y > HEIGHT + ENEMY_H:
 64            self.escaped.emit()
 65            self.destroy()
 66
 67    def on_draw(self, renderer):
 68        renderer.draw_rect((self.position.x - ENEMY_W / 2, self.position.y - ENEMY_H / 2),
 69                           (ENEMY_W, ENEMY_H), colour=(1.0, 0.4, 0.3, 1), filled=True)
 70
 71
 72class Spawner(Node2D):
 73    """No drawing: just a timer that emits each new enemy for the root to place."""
 74
 75    min_delay = Property(0.4)
 76    max_delay = Property(0.9)
 77
 78    def on_ready(self):
 79        self._timer = 0.0
 80        self.spawned = Signal()  # carries the new Enemy node
 81
 82    def on_update(self, dt: float):
 83        self._timer -= dt
 84        if self._timer <= 0:
 85            self._timer = random.uniform(float(self.min_delay), float(self.max_delay))
 86            enemy = Enemy(random.uniform(150.0, 300.0), position=Vec2(random.uniform(20, WIDTH - 20), -ENEMY_H))
 87            self.spawned.emit(enemy)
 88
 89
 90class ScoreLabel(Node2D):
 91    """Owns the score and its rendering; announces when it changes."""
 92
 93    score = Property(0)
 94
 95    def on_ready(self):
 96        self.changed = Signal()
 97
 98    def add(self, n: int = 1):
 99        self.score = int(self.score) + n
100        self.changed.emit(int(self.score))
101
102    def on_draw(self, renderer):
103        renderer.draw_text(f"Score: {int(self.score)}", (20, 20), scale=2, colour=(1, 1, 1, 1))
104
105
106class DodgeGame(Node2D):
107    """Root: owns the graph (which nodes exist, which signals wire to which slots)
108    and the one thing only it can see, player-versus-enemy collision."""
109
110    input_actions = {
111        "left": [Key.A, Key.LEFT],
112        "right": [Key.D, Key.RIGHT],
113        "restart": [Key.ENTER],
114    }
115
116    def on_ready(self):
117        self._start()
118
119    def _start(self):
120        self.alive = True
121        self.player = self.add_child(Player(position=Vec2(WIDTH / 2, HEIGHT - 60)))
122        self.spawner = self.add_child(Spawner())
123        self.score = self.add_child(ScoreLabel())
124        self.spawner.spawned.connect(self._on_spawned)
125        self.score.changed.connect(self._on_score_changed)
126
127    def _on_spawned(self, enemy: Enemy):
128        # The root decides where the enemy lives, and pairs its escape to a point.
129        enemy.escaped.connect(lambda: self.score.add(1))
130        self.add_child(enemy)
131
132    def _on_score_changed(self, score: int):
133        # A rising score ramps the difficulty: the root tightens the spawn delay.
134        self.spawner.min_delay = max(0.12, 0.4 - score * 0.01)
135        self.spawner.max_delay = max(0.3, 0.9 - score * 0.02)
136
137    def on_update(self, dt: float):
138        if not self.alive:
139            if Input.is_action_just_pressed("restart"):
140                self.clear_children()
141                self._start()
142            return
143        px, py = self.player.position
144        for child in list(self.children):
145            if isinstance(child, Enemy) and abs(child.position.x - px) < (PLAYER_W + ENEMY_W) / 2 \
146                    and abs(child.position.y - py) < (PLAYER_H + ENEMY_H) / 2:
147                self.alive = False
148                return
149
150    def on_draw(self, renderer):
151        renderer.draw_rect((0, 0), (WIDTH, HEIGHT), colour=(0.05, 0.05, 0.08, 1), filled=True)
152        if not self.alive:
153            renderer.draw_text(
154                "Game Over: Enter to restart", (WIDTH / 2 - 150, HEIGHT / 2), scale=2, colour=(1, 1, 0.3, 1)
155            )
156
157
158if __name__ == "__main__":
159    App(title="Monolith to Composed", width=WIDTH, height=HEIGHT).run(DodgeGame())