From monolithic to composed¶
How to turn a game that lives in one node into small nodes that talk through signals, and why it pays off. The game is Dodge: slide a paddle along the bottom and avoid the falling blocks. Get the finished, composed version and run it:
simvx examples copy tutorials_monolith_to_composed --to dodge
python dodge/main.py
From a source checkout, prefix commands with uv run. Read the
whole file on the source host.
Why compose¶
One node does everything |
Small nodes and signals |
|---|---|
One class, quick to start |
A few classes, a little more typing |
Every behaviour can read every other’s state |
Each node owns its own state |
|
Each |
A test has to build the whole game |
Each node can be tested on its own |
The editor shows one big node |
The scene tree shows the real structure |
The starting point: one node¶
This version works. It is a sketch to compare against, not a shipped example:
import random
from simvx.core import Axis1D, Controller, Input, Key, Node2D, Pointer, Vec2
from simvx.graphics import App
PLAYER_SIZE = Vec2(80, 16)
ENEMY_SIZE = Vec2(40, 40)
class DodgeGame(Node2D):
dynamic = True # on_draw reads plain fields that change every frame
input_context = {
"move": [Axis1D(low=Key.A, high=Key.D), Axis1D(low=Key.LEFT, high=Key.RIGHT), Controller.LEFT_X],
"restart": [Key.ENTER, Pointer.PRIMARY],
}
def on_ready(self):
self.player_x = self.viewport_size.x / 2
self.enemies = [] # [x, y, speed] for each falling block
self.spawn_in = 0.0
self.score = 0
self.alive = True
def on_update(self, dt: float):
width, height = self.viewport_size
if not self.alive:
if Input.just_pressed("restart"):
self.on_ready()
return
half = PLAYER_SIZE.x / 2
self.player_x = max(half, min(width - half, self.player_x + Input.value("move") * 360 * dt))
self.spawn_in -= dt
if self.spawn_in <= 0:
self.spawn_in = random.uniform(0.4, 0.9)
self.enemies.append([random.uniform(20, width - 20), -ENEMY_SIZE.y, random.uniform(150, 300)])
for enemy in self.enemies:
enemy[1] += enemy[2] * dt
escaped = [e for e in self.enemies if e[1] > height + ENEMY_SIZE.y]
self.score += len(escaped)
self.enemies = [e for e in self.enemies if e not in escaped]
reach = (PLAYER_SIZE + ENEMY_SIZE) / 2
player_y = height - 60
for x, y, _ in self.enemies:
if abs(x - self.player_x) < reach.x and abs(y - player_y) < reach.y:
self.alive = False
def on_draw(self, renderer):
width, height = self.viewport_size
renderer.rect((0, 0), (width, height), fill=(0.05, 0.05, 0.08, 1))
renderer.rect((self.player_x, height - 60) - PLAYER_SIZE / 2, PLAYER_SIZE, fill=(0.5, 0.9, 1.0, 1))
for x, y, _ in self.enemies:
renderer.rect((x, y) - ENEMY_SIZE / 2, ENEMY_SIZE, fill=(1.0, 0.4, 0.3, 1))
renderer.text(f"Score: {self.score}", (20, 20), fill=(1, 1, 1, 1), scale=2)
if not self.alive:
renderer.text(
"Game Over: Enter or tap to restart", (width / 2, height / 2), fill=(1, 1, 0.3, 1), scale=2, align="centre"
)
if __name__ == "__main__":
App(title="Dodge", width=800, height=600).run(DodgeGame())
DodgeGame is a player controller, a spawner, an enemy fleet, a collision check, a score
counter and a HUD in one class. Its state is plain fields that change every frame, so it needs
dynamic = True to redraw at all. The composed version below drops it: each node draws from a
Property or its own transform, and those redraw by themselves.
Step 1: the player¶
The player has one piece of state, its position, and one job: move and stay on screen.
class Player(Node2D):
"""Moves left and right, from the `move` action or toward a held pointer."""
speed = Property(360.0, range=(50, 800))
def on_update(self, dt: float):
dx = Input.value("move")
if Input.pressed("steer"):
dx = max(-1.0, min(1.0, (Input.mouse_position.x - self.position.x) / 32.0))
half = PLAYER_SIZE.x / 2
x = max(half, min(self.viewport_size.x - half, self.position.x + dx * self.speed * dt))
self.position = (x, self.position.y)
def on_draw(self, renderer):
renderer.rect(-PLAYER_SIZE / 2, PLAYER_SIZE, fill=(0.5, 0.9, 1.0, 1))
speed is a Property, so the editor shows it, and the paddle draws itself around its own
position. steer is the held pointer, so a finger on a phone moves the paddle too.
Step 2: the enemy and the spawner¶
An enemy falls, and when it leaves the bottom it calls its escaped signal and removes itself:
class Enemy(Node2D):
"""Falls straight down and announces when it leaves the bottom of the screen."""
speed = Property(220.0, range=(50, 600))
escaped = Signal()
def on_update(self, dt: float):
self.position += (0, self.speed * dt)
if self.position.y > self.viewport_size.y + ENEMY_SIZE.y:
self.escaped.emit()
self.destroy()
def on_draw(self, renderer):
renderer.rect(-ENEMY_SIZE / 2, ENEMY_SIZE, fill=(1.0, 0.4, 0.3, 1))
The spawner draws nothing. A repeating Timer calls spawn, which emits a new enemy through
spawned:
class Spawner(Node):
"""Draws nothing: after each random delay it emits a new Enemy for the root to place."""
min_delay = Property(0.4)
max_delay = Property(0.9)
spawned = Signal(Enemy)
def on_ready(self):
self.timer = self.add_child(Timer(duration=self.min_delay, one_shot=False, autostart=True))
self.timer.timeout.connect(self.spawn)
def spawn(self):
x = random.uniform(20, self.viewport_size.x - 20)
self.spawned.emit(Enemy(speed=random.uniform(150, 300), position=(x, -ENEMY_SIZE.y)))
self.timer.duration = random.uniform(self.min_delay, self.max_delay)
The spawner does not decide where the enemy goes in the tree. Whoever listens to spawned
does.
Step 3: the score¶
class ScoreLabel(Node2D):
"""Holds the score and draws it; announces each change."""
score = Property(0)
changed = Signal(int)
def add(self, points: int = 1):
self.score += points
self.changed.emit(self.score)
def on_draw(self, renderer):
renderer.text(f"Score: {self.score}", (20, 20), fill=(1, 1, 1, 1), scale=2)
Step 4: the root wires the graph¶
The root owns no per-entity state. It decides which nodes exist, which signals connect to which methods, and the one thing only it can see: the player hitting an enemy.
class DodgeGame(Node2D):
"""Builds each round, wires its signals, and checks the one collision only it can see."""
# Keeps ticking while the tree is paused, so it can hear the restart.
update_mode = Property(UpdateMode.ALWAYS)
game_over = Property(False)
# `steer` and `restart` share the primary pointer, so a finger both plays and starts again.
input_context = {
"move": [Axis1D(low=Key.A, high=Key.D), Axis1D(low=Key.LEFT, high=Key.RIGHT), Controller.LEFT_X],
"steer": [Pointer.PRIMARY],
"restart": [Key.ENTER, Pointer.PRIMARY],
}
def on_ready(self):
self.start_round()
def start_round(self):
self.game_over = False
self.tree.paused = False
# Everything that plays lives under one node, so one flag freezes it all
# and one destroy() tears the round down.
self.world = self.add_child(Node2D(name="World", update_mode=UpdateMode.PAUSABLE))
width, height = self.viewport_size
self.player = self.world.add_child(Player(position=(width / 2, height - 60)))
self.score = self.world.add_child(ScoreLabel())
self.spawner = self.world.add_child(Spawner())
self.spawner.spawned.connect(self.place_enemy)
self.score.changed.connect(self.ramp_difficulty)
def place_enemy(self, enemy: Enemy):
enemy.escaped.connect(self.score.add)
self.world.add_child(enemy)
def ramp_difficulty(self, score: int):
self.spawner.min_delay = max(0.12, 0.4 - score * 0.01)
self.spawner.max_delay = max(0.3, 0.9 - score * 0.02)
def on_update(self, dt: float):
if self.game_over:
if Input.just_pressed("restart"):
self.world.destroy()
self.start_round()
return
reach = (PLAYER_SIZE + ENEMY_SIZE) / 2
for enemy in self.world.find_all(Enemy, direct=True):
gap = abs(enemy.position - self.player.position)
if gap.x < reach.x and gap.y < reach.y:
self.game_over = True
self.tree.paused = True
return
def on_draw(self, renderer):
width, height = self.viewport_size
renderer.rect((0, 0), (width, height), fill=(0.05, 0.05, 0.08, 1))
if self.game_over:
renderer.text(
"Game Over: Enter or tap to restart",
(width / 2, height / 2),
fill=(1, 1, 0.3, 1),
scale=2,
align="centre",
)
Every gameplay node lives under one
Worldnode, sotree.paused = Truefreezes the whole round at once andworld.destroy()tears it down.The root has
update_mode = Property(UpdateMode.ALWAYS), so it keeps running through the pause and hears the restart.Worldis built withupdate_mode=UpdateMode.PAUSABLE, so it does not inherit that.place_enemyconnects each new enemy’sescapedtoScoreLabel.add, andScoreLabel.changedreachesramp_difficulty, which shortens the spawn delay as the score rises.
The signal-pairing rule¶
Every signal has an emitter and a listener. A signal nothing connects to is not an API, it is noise: delete it. When you reach for a global or a parent’s attribute, ask first whether the node that owns the data can emit a signal and the node that needs it can connect.
Testing payoff¶
A composed node can be tested on its own. This test builds one Enemy, with no game around it:
from simvx.core.testing import SceneRunner
from main import Enemy
def test_an_enemy_announces_its_escape_once():
escapes = []
enemy = Enemy(speed=600, position=(400, 590))
enemy.escaped.connect(lambda: escapes.append(enemy))
SceneRunner().load(enemy).advance_frames(10)
assert escapes == [enemy]
In the monolith, every test is a test of the whole game.
When not to compose¶
Split when state has more than one owner, when a behaviour needs testing on its own, or when a later feature (a replay, an AI player) will need to hear events. A 30-line title screen or a debug overlay can stay one node.
See also¶
Node system: the node lifecycle and the child API.
Input: input actions and contexts.
Testing:
SceneRunnerandInputSimulator.Your first 2D game: Pong, another game built from small nodes.