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 (arrow keys, A/D, or hold the pointer) 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

World

a bare Node2D that holds one round, so it can be frozen or torn down as a unit

DodgeGame (root)

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

Signals connect them

Each node declares its signals at class scope, exactly like it declares its properties, and stays ignorant of who is listening. The root does the wiring:

class Spawner(Node):
    spawned = Signal(Enemy)        # declared once; each instance gets its own

    def spawn(self):
        self.spawned.emit(Enemy(...))   # fire the signal

...

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

def place_enemy(self, enemy):
    enemy.escaped.connect(self.score.add)   # dodged -> +1
    self.world.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.

Composition makes “stop the game” one line

The monolith froze on death for free: one node, one if alive guard. Split into five nodes, that guard only stops the root, and the spawner keeps spawning behind the Game Over text. The composed answer is not five guards, it is one grouping node: every gameplay node is a child of a World node, so tree.paused = True freezes the whole subtree at once. The root declares update_mode = Property(UpdateMode.ALWAYS) so it keeps ticking through the pause and can hear the restart, and World is built with update_mode=UpdateMode.PAUSABLE so it does not inherit that exemption.

The payoff

The root’s on_update shrinks to pure game flow (game-over 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

# In your own copy of this directory
python main.py

# Anywhere, with the example library installed
simvx examples run tutorials_monolith_to_composed

Source

  1# /// simvx
  2# tags = ["tutorial", "architecture", "signals"]
  3# web = { width = 800, height = 600, responsive = true }
  4# ///
  5"""Monolith to Composed: Structure a game as small, single-purpose nodes.
  6
  7The capstone of the basics track. This is the *composed* version of a tiny dodge
  8game: slide the paddle along the bottom with the arrow keys, A/D or a stick, or
  9hold the pointer (a finger on a phone) where you want it to go, and avoid the
 10falling blocks. Each block you dodge scores a point. The lesson is the structure,
 11not the game: instead of one giant node that does input, spawning, scoring and
 12collision, every concern is its own node, and they talk through signals. The
 13README walks through the refactor from the monolith; this file is the result.
 14
 15## What you will learn
 16
 17- **One node, one job.** `Player` moves, `Enemy` falls, `Spawner` produces enemies
 18  on a `Timer`, and `ScoreLabel` holds and draws the score. Each is short and can
 19  be tested on its own.
 20- **Paired signals.** Signals are declared at class scope (`escaped = Signal()`)
 21  and emitted with `emit` (`self.escaped.emit()`). Every one here has an emitter
 22  and a listener: `Spawner.spawned` reaches the root, which places the enemy;
 23  `Enemy.escaped` reaches `ScoreLabel.add`; `ScoreLabel.changed` reaches the root,
 24  which shortens the spawn delay as the score rises. A signal with no listener,
 25  or a listener with no emitter, is a sign the structure is wrong.
 26- **The root owns the graph, not the state.** `DodgeGame` decides which nodes
 27  exist and which signals wire to which slots, plus the one thing only it can
 28  see: player-versus-enemy collision.
 29- **One flag freezes the whole game.** Every gameplay node lives under one
 30  `World` node with `update_mode=UpdateMode.PAUSABLE`, so a hit sets
 31  `tree.paused` and the spawner, the enemies and the scoring all stop at once.
 32  The root declares `update_mode = Property(UpdateMode.ALWAYS)`, so it keeps
 33  ticking through the pause and hears Enter or a tap, which destroys `World` and
 34  builds a fresh round.
 35"""
 36
 37import random
 38
 39from simvx.core import Axis1D, Controller, Input, Key, Node, Node2D, Pointer, Property, Signal, Timer, UpdateMode, Vec2
 40from simvx.graphics import App
 41
 42PLAYER_SIZE = Vec2(80, 16)
 43ENEMY_SIZE = Vec2(40, 40)
 44
 45
 46class Player(Node2D):
 47    """Moves left and right, from the `move` action or toward a held pointer."""
 48
 49    speed = Property(360.0, range=(50, 800))
 50
 51    def on_update(self, dt: float):
 52        dx = Input.value("move")
 53        if Input.pressed("steer"):
 54            dx = max(-1.0, min(1.0, (Input.mouse_position.x - self.position.x) / 32.0))
 55        half = PLAYER_SIZE.x / 2
 56        x = max(half, min(self.viewport_size.x - half, self.position.x + dx * self.speed * dt))
 57        self.position = (x, self.position.y)
 58
 59    def on_draw(self, renderer):
 60        renderer.rect(-PLAYER_SIZE / 2, PLAYER_SIZE, fill=(0.5, 0.9, 1.0, 1))
 61
 62
 63class Enemy(Node2D):
 64    """Falls straight down and announces when it leaves the bottom of the screen."""
 65
 66    speed = Property(220.0, range=(50, 600))
 67
 68    escaped = Signal()
 69
 70    def on_update(self, dt: float):
 71        self.position += (0, self.speed * dt)
 72        if self.position.y > self.viewport_size.y + ENEMY_SIZE.y:
 73            self.escaped.emit()
 74            self.destroy()
 75
 76    def on_draw(self, renderer):
 77        renderer.rect(-ENEMY_SIZE / 2, ENEMY_SIZE, fill=(1.0, 0.4, 0.3, 1))
 78
 79
 80class Spawner(Node):
 81    """Draws nothing: after each random delay it emits a new Enemy for the root to place."""
 82
 83    min_delay = Property(0.4)
 84    max_delay = Property(0.9)
 85
 86    spawned = Signal(Enemy)
 87
 88    def on_ready(self):
 89        self.timer = self.add_child(Timer(duration=self.min_delay, one_shot=False, autostart=True))
 90        self.timer.timeout.connect(self.spawn)
 91
 92    def spawn(self):
 93        x = random.uniform(20, self.viewport_size.x - 20)
 94        self.spawned.emit(Enemy(speed=random.uniform(150, 300), position=(x, -ENEMY_SIZE.y)))
 95        self.timer.duration = random.uniform(self.min_delay, self.max_delay)
 96
 97
 98class ScoreLabel(Node2D):
 99    """Holds the score and draws it; announces each change."""
100
101    score = Property(0)
102
103    changed = Signal(int)
104
105    def add(self, points: int = 1):
106        self.score += points
107        self.changed.emit(self.score)
108
109    def on_draw(self, renderer):
110        renderer.text(f"Score: {self.score}", (20, 20), fill=(1, 1, 1, 1), scale=2)
111
112
113class DodgeGame(Node2D):
114    """Builds each round, wires its signals, and checks the one collision only it can see."""
115
116    # Keeps ticking while the tree is paused, so it can hear the restart.
117    update_mode = Property(UpdateMode.ALWAYS)
118    game_over = Property(False)
119
120    # `steer` and `restart` share the primary pointer, so a finger both plays and starts again.
121    input_context = {
122        "move": [Axis1D(low=Key.A, high=Key.D), Axis1D(low=Key.LEFT, high=Key.RIGHT), Controller.LEFT_X],
123        "steer": [Pointer.PRIMARY],
124        "restart": [Key.ENTER, Pointer.PRIMARY],
125    }
126
127    def on_ready(self):
128        self.start_round()
129
130    def start_round(self):
131        self.game_over = False
132        self.tree.paused = False
133        # Everything that plays lives under one node, so one flag freezes it all
134        # and one destroy() tears the round down.
135        self.world = self.add_child(Node2D(name="World", update_mode=UpdateMode.PAUSABLE))
136        width, height = self.viewport_size
137        self.player = self.world.add_child(Player(position=(width / 2, height - 60)))
138        self.score = self.world.add_child(ScoreLabel())
139        self.spawner = self.world.add_child(Spawner())
140        self.spawner.spawned.connect(self.place_enemy)
141        self.score.changed.connect(self.ramp_difficulty)
142
143    def place_enemy(self, enemy: Enemy):
144        enemy.escaped.connect(self.score.add)
145        self.world.add_child(enemy)
146
147    def ramp_difficulty(self, score: int):
148        self.spawner.min_delay = max(0.12, 0.4 - score * 0.01)
149        self.spawner.max_delay = max(0.3, 0.9 - score * 0.02)
150
151    def on_update(self, dt: float):
152        if self.game_over:
153            if Input.just_pressed("restart"):
154                self.world.destroy()
155                self.start_round()
156            return
157        reach = (PLAYER_SIZE + ENEMY_SIZE) / 2
158        for enemy in self.world.find_all(Enemy, direct=True):
159            gap = abs(enemy.position - self.player.position)
160            if gap.x < reach.x and gap.y < reach.y:
161                self.game_over = True
162                self.tree.paused = True
163                return
164
165    def on_draw(self, renderer):
166        width, height = self.viewport_size
167        renderer.rect((0, 0), (width, height), fill=(0.05, 0.05, 0.08, 1))
168        if self.game_over:
169            renderer.text(
170                "Game Over: Enter or tap to restart",
171                (width / 2, height / 2),
172                fill=(1, 1, 0.3, 1),
173                scale=2,
174                align="centre",
175            )
176
177
178if __name__ == "__main__":
179    App(title="Monolith to Composed", width=800, height=600).run(DodgeGame())