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 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 timer that emits each new enemy |
|
hold the score and draw it |
|
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())