Monolith to Composed¶
Structure a game as small, single-purpose nodes.
▶ Run in browserTags: 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 |
|---|---|
|
read input, move, clamp, draw itself |
|
fall, and announce when it escapes off the bottom |
|
a |
|
hold the score and draw it |
|
a bare |
|
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())