Testing Your Game¶
a coin collector built to be tested.
▶ Run in browserTags: tutorial testing signals
Testing Your Game¶
Games rot without tests: a tweak to movement speed breaks a pickup, a refactor drops a signal connection, and nobody notices until a player does. This tutorial builds a deliberately tiny game, Coin Run (walk a square into six coins, collect them all to win), and then tests it at three levels, each catching a class of bug the previous one cannot. Everything runs headless: no window, no GPU, fast enough for a pre-commit hook.
# Play it
uv run python examples/tutorials/testing_your_game/main.py
# Test it
uv run pytest examples/tutorials/testing_your_game/test_game.py
Structuring a game so it can be tested¶
Testability is decided when the game is written, not when the tests are. Coin Run makes three deliberate choices:
The rules are pure functions.
clamp_to_field()andwithin_pickup()take values and return values. They can be tested with no engine running.The world is deterministic. Coins spawn at fixed
COIN_SPOTS, so a test knows exactly where everything is. A game that needs randomness should take a seed for the same reason.Events cross node boundaries as signals. A coin emits
collected; the root emitsscore_changed. Tests subscribe to these seams instead of reaching into internals, so they keep passing across refactors that preserve behaviour.
Level 1: unit tests of the pure rules¶
The cheapest tests, and the ones that pin the game’s numbers down. No nodes are created and no tree exists; the functions are called directly:
def test_pickup_radius_is_a_hard_boundary(self):
coin = Vec2(300.0, 300.0)
assert within_pickup(coin + Vec2(PICKUP_RADIUS, 0.0), coin)
assert not within_pickup(coin + Vec2(PICKUP_RADIUS + 0.1, 0.0), coin)
These catch the off-by-one and boundary bugs, and they document intent: the radial test asserts pickup range is a circle, not a box, so a future “optimisation” to axis-aligned distance fails loudly.
What they cannot catch: wiring. The rules can be perfect while the game never calls them.
Level 2: scene-tree tests with SceneRunner¶
SceneRunner (from simvx.core.testing) mounts a real root into a real
SceneTree and ticks frames on demand. Lifecycle hooks run, signals fire,
and deferred destruction happens at real frame boundaries, all without a
window:
runner = SceneRunner(screen_size=(WIDTH, HEIGHT))
root = CoinRun(name="CoinRun")
runner.load(root)
runner.advance_frames(1) # on_ready has run; coins exist
coin = root.coins[0]
root.player.position = Vec2(coin.position.x, coin.position.y)
runner.advance_frames(2) # one frame to collect, one for the destroy
assert root.score == 1
assert len(runner.find_all(Coin)) == len(COIN_SPOTS) - 1
The teleport is the point: level 2 tests state transitions, so they place
the world into the interesting state directly rather than simulating the
journey there. runner.find() / find_all() query the tree, and
advance_frames(2) matters because destroy() is deferred to the end of
the frame; a test that only advanced one frame would still see the coin.
The “collected exactly once” test is a level-2 speciality: the player lingers on the spot for ten frames and the score must still be 1. Per-frame logic that should fire once is one of the most common game bug shapes.
What these cannot catch: input wiring. The player node was moved by the test’s hand, so a broken key binding or a typo in an action name would never show up.
Level 3: input-driven tests with InputSimulator¶
InputSimulator writes the same state a platform keyboard adapter writes,
so Input.get_vector() inside the player’s on_update sees a real press.
The player reaches the coin because the game moved it, and the test only
observes the outcome through the score_changed signal, exactly like a HUD
would:
events = []
root.score_changed.connect(events.append)
root.player.position = Vec2(coin.position.x - 80.0, coin.position.y)
sim = InputSimulator()
sim.press_key(Key.D)
collected = runner.simulate_until(lambda r: r.score > 0, max_ticks=180)
sim.release_key(Key.D)
assert collected
assert events == [1]
simulate_until ticks until the predicate holds or the frame budget runs
out, which keeps the test independent of the exact walk duration. This
level exercises the whole chain: input_actions registration at mount,
the action query, movement, clamping, pickup, signal. Any link breaking
fails the test.
One discipline comes with the power: Input is a process-wide singleton,
so a test that presses a key must release it even when the test fails. The
autouse fixture handles it:
@pytest.fixture(autouse=True)
def _clean_input():
yield
InputSimulator().reset()
Which level should a given test be?¶
Push every test to the cheapest level that can catch its bug. Rules and formulas: level 1. State machines, spawning, scoring, win conditions: level 2. Bindings and the full player-facing loop: a few level 3 tests are enough, because everything below them is already covered. A suite shaped like this pyramid stays fast, and fast suites are the ones that get run.
The --test self-check in main.py condenses all three levels into one
headless run, the convention every example in this repository follows:
uv run python examples/tutorials/testing_your_game/main.py --test
What’s next¶
The project samples under
examples/projects/each carry a full pytest suite intests/, laid out exactly like this tutorial at larger scale.For rendering-level verification (does it look right), see the golden and screenshot tooling used by the engine’s own test suites; pixels are deliberately out of scope for game-logic tests.
Source files¶
File |
Summary |
Lines |
|---|---|---|
Testing Your Game: a coin collector built to be tested. |
213 |
|
Three levels of headless tests for the Coin Run game in |
160 |
Source¶
1"""Testing Your Game: a coin collector built to be tested.
2
3A tiny but complete game whose real point is the test suite beside it.
4Walk the square with WASD or the arrow keys and collect all six coins to
5win. The game rules are pure functions, the state lives on nodes, and the
6score announces itself through a signal, so every layer can be tested
7headless with pytest. See `test_game.py` for the three levels of tests
8and `README.md` for why each level exists.
9
10# /// simvx
11# tags = ["tutorial", "testing", "signals"]
12# web = { root = "CoinRun", width = 960, height = 540, responsive = true }
13# ///
14
15## What you will learn
16
17- **Structure for testability**: keep the rules (clamping, pickup range) as
18 pure functions so they can be tested without an engine at all.
19- **SceneRunner**: drive a real scene tree frame by frame with no window.
20- **InputSimulator**: feed keys through the real input path so a test plays
21 the game the way a player would.
22- **Signals as test seams**: the game emits `score_changed`; tests subscribe
23 to it instead of poking at internals.
24
25## How it works
26
27`CoinRun` (root) declares its `input_actions`, spawns the `Player` and six
28`Coin` nodes at fixed spots, and checks pickup range each frame. A touched
29coin emits `collected` and destroys itself; the root's handler bumps the
30score, emits `score_changed`, and flips `won` when the field is clear. The
31fixed layout and pure rule functions are what make the tests deterministic.
32
33Run: uv run python examples/tutorials/testing_your_game/main.py
34Headless self-check: uv run python examples/tutorials/testing_your_game/main.py --test
35Full test suite: uv run pytest examples/tutorials/testing_your_game/test_game.py
36"""
37
38from simvx.core import Input, Key, Node2D, Signal, Vec2
39from simvx.graphics import App
40
41WIDTH, HEIGHT = 960, 540
42MARGIN = 24.0 # the walkable field is the window minus this border
43PLAYER_SPEED = 300.0
44PLAYER_HALF = 14.0
45COIN_RADIUS = 10.0
46PICKUP_RADIUS = 26.0
47
48# A fixed layout, not a random one: deterministic worlds make deterministic
49# tests. If your game needs randomness, take a seed so tests can pin it.
50COIN_SPOTS = (
51 (160.0, 120.0),
52 (480.0, 90.0),
53 (800.0, 120.0),
54 (160.0, 420.0),
55 (480.0, 450.0),
56 (800.0, 420.0),
57)
58
59
60def clamp_to_field(pos: Vec2) -> Vec2:
61 """Return `pos` clamped to the walkable field.
62
63 A pure function: no nodes, no engine state. This is the first thing the
64 test suite covers, because it needs nothing running to be tested.
65 """
66 return Vec2(
67 min(max(pos.x, MARGIN), WIDTH - MARGIN),
68 min(max(pos.y, MARGIN), HEIGHT - MARGIN),
69 )
70
71
72def within_pickup(player_pos: Vec2, coin_pos: Vec2) -> bool:
73 """True when the player is close enough to `coin_pos` to collect it."""
74 return (player_pos - coin_pos).length() <= PICKUP_RADIUS
75
76
77class Player(Node2D):
78 """A square driven by the four movement actions, clamped to the field."""
79
80 def on_update(self, dt: float):
81 direction = Input.get_vector("move_left", "move_right", "move_up", "move_down")
82 if direction.x or direction.y:
83 self.position = clamp_to_field(self.position + direction * PLAYER_SPEED * dt)
84
85 def on_draw(self, renderer):
86 x, y = self.position.x - PLAYER_HALF, self.position.y - PLAYER_HALF
87 renderer.draw_rect((x, y), (PLAYER_HALF * 2, PLAYER_HALF * 2), colour=(0.35, 0.8, 0.95, 1.0), filled=True)
88
89
90class Coin(Node2D):
91 """A coin that announces its own collection, then removes itself.
92
93 The coin never touches the score. It emits `collected` and lets whoever
94 listens decide what that means, which is exactly the seam the tests use.
95 """
96
97 collected = Signal()
98
99 def collect(self):
100 self.collected.emit()
101 self.destroy()
102
103 def on_draw(self, renderer):
104 renderer.draw_circle(self.position, COIN_RADIUS, colour=(1.0, 0.8, 0.2, 1.0), filled=True)
105
106
107class CoinRun(Node2D):
108 score_changed = Signal(int)
109
110 input_actions = {
111 "move_left": [Key.A, Key.LEFT],
112 "move_right": [Key.D, Key.RIGHT],
113 "move_up": [Key.W, Key.UP],
114 "move_down": [Key.S, Key.DOWN],
115 }
116
117 def on_ready(self):
118 self.score = 0
119 self.won = False
120 self.player = self.add_child(Player(name="Player", position=Vec2(WIDTH / 2, HEIGHT / 2)))
121 self.coins = [self.add_child(Coin(name=f"Coin{i}", position=Vec2(*spot))) for i, spot in enumerate(COIN_SPOTS)]
122 for coin in self.coins:
123 coin.collected.connect(self._on_collected)
124
125 def on_update(self, dt: float):
126 if self.won:
127 return
128 for coin in list(self.coins):
129 if within_pickup(self.player.position, coin.position):
130 self.coins.remove(coin)
131 coin.collect()
132
133 def _on_collected(self):
134 self.score += 1
135 self.score_changed.emit(self.score)
136 if self.score == len(COIN_SPOTS):
137 self.won = True
138 # The scoreboard changes only here, so ask for a one-shot repaint
139 # rather than marking the whole node `dynamic`.
140 self.queue_redraw()
141
142 def on_draw(self, renderer):
143 # The field border: four thin rects along the margin line.
144 w, h = WIDTH - 2 * MARGIN, HEIGHT - 2 * MARGIN
145 border = (0.3, 0.3, 0.35, 1.0)
146 renderer.draw_rect((MARGIN, MARGIN), (w, 2), colour=border, filled=True)
147 renderer.draw_rect((MARGIN, HEIGHT - MARGIN - 2), (w, 2), colour=border, filled=True)
148 renderer.draw_rect((MARGIN, MARGIN), (2, h), colour=border, filled=True)
149 renderer.draw_rect((WIDTH - MARGIN - 2, MARGIN), (2, h), colour=border, filled=True)
150 score_line = f"Coins: {self.score}/{len(COIN_SPOTS)}"
151 renderer.draw_text(score_line, (MARGIN + 12, MARGIN + 12), scale=2, colour=(1.0, 1.0, 1.0))
152 hint_pos = (MARGIN + 12, HEIGHT - MARGIN - 24)
153 renderer.draw_text("WASD or arrows to move", hint_pos, scale=1, colour=(0.5, 0.5, 0.55))
154 if self.won:
155 banner_pos = (WIDTH / 2 - 170, HEIGHT / 2 - 60)
156 renderer.draw_text("Field clear! You win.", banner_pos, scale=3, colour=(0.4, 1.0, 0.5))
157
158
159def _selftest() -> bool:
160 """Headless: the same checks test_game.py makes, condensed into one run."""
161 from simvx.core.testing import InputSimulator, SceneRunner
162
163 ok = True
164
165 def check(label: str, passed: bool, detail: str = "") -> None:
166 nonlocal ok
167 ok = ok and passed
168 print(f"{'ok ' if passed else 'FAIL'} {label}" + (f": {detail}" if detail else ""))
169
170 # Level 1: the pure rules need no engine at all.
171 clamped = clamp_to_field(Vec2(-50.0, 9999.0))
172 check("the clamp holds a runaway position inside the field", clamped.x == MARGIN and clamped.y == HEIGHT - MARGIN)
173 spot = Vec2(300.0, 300.0)
174 on_edge = within_pickup(spot + Vec2(PICKUP_RADIUS, 0.0), spot)
175 past_edge = within_pickup(spot + Vec2(PICKUP_RADIUS + 0.1, 0.0), spot)
176 check("the pickup radius is a hard boundary", on_edge and not past_edge)
177
178 # Levels 2 and 3: a real tree, driven by real input, with no window.
179 runner = SceneRunner(screen_size=(WIDTH, HEIGHT))
180 game = CoinRun(name="CoinRun")
181 runner.load(game)
182 runner.advance_frames(1)
183 fresh = game.score == 0 and len(runner.find_all(Coin)) == len(COIN_SPOTS)
184 check("the scene starts with every coin and no score", fresh)
185
186 scores: list[int] = []
187 game.score_changed.connect(scores.append)
188 target = game.coins[0]
189 game.player.position = Vec2(target.position.x - 60.0, target.position.y)
190 sim = InputSimulator()
191 sim.press_key(Key.D)
192 reached = runner.simulate_until(lambda root: root.score > 0, max_ticks=180)
193 sim.release_key(Key.D)
194 check("walking right collects a coin and fires score_changed", reached and scores == [1], f"score events {scores}")
195 runner.advance_frames(2)
196 check("the collected coin left the tree", len(runner.find_all(Coin)) == len(COIN_SPOTS) - 1)
197
198 for coin in list(game.coins):
199 game.player.position = Vec2(coin.position.x, coin.position.y)
200 runner.advance_frames(2)
201 check("clearing the field wins the game", game.won and game.score == len(COIN_SPOTS), f"score {game.score}")
202
203 sim.reset()
204 print("SELFTEST:", "PASS" if ok else "FAIL")
205 return ok
206
207
208if __name__ == "__main__":
209 import sys
210
211 if "--test" in sys.argv:
212 sys.exit(0 if _selftest() else 1)
213 App(title="Coin Run", width=WIDTH, height=HEIGHT).run(CoinRun())