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.
The square moves on WASD, the arrow keys or a controller’s left stick, and it
steers towards a held pointer, which is how it plays on a phone: steer is
bound to Pointer.PRIMARY, the main press of a mouse, a pen or a finger.
# Play it
simvx examples run tutorials_testing_your_game
# 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(),coin_positions()andwithin_pickup()take values and return values. They can be tested with no engine running. Note whatclamp_to_field()does not read: a module-level window size. The field is a parameter, so the rule is right in any window and a test can state the one it means.The world is deterministic. Coins spawn at fixed
COIN_SPOTS, so a test knows exactly where everything is. The spots are fractions of the field rather than pixel coordinates, which keeps them deterministic and correct on a window the game was not written for. 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=(FIELD.x, FIELD.y))
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.
screen_size is the runner’s, not the game’s: the game measures its own field
from self.viewport_size every time it needs it, so a test picks the window it
wants and the game lays itself out in it. Resizing mid-run is therefore a
level-2 test like any other, and one worth having, because a layout that is
secretly baked passes every test taken at one size:
runner.tree.screen_size = (FIELD.x / 2, FIELD.y / 2)
runner.advance_frames(1)
assert root.player.position.x == FIELD.x / 2 - MARGIN # pulled back inside
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 adapter writes, so
Input.value("move") 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(runner.tree) # the tree the game runs in
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: the root’s input_context coming into force at mount,
the action query, movement, clamping, pickup, signal. Any link breaking
fails the test.
One test per device the game claims to support¶
move is one action with three sources, so the movement code is written once,
but “the stick is bound” and “the stick arrives” are different claims. Each
device the game promises gets a test that presses it:
sim.set_controller_axis(Controller.LEFT_X, 1.0) # the controller
runner.advance_frames(30)
assert root.player.position.x > start.x + 100.0
sim.press_mouse(Mouse.LEFT, (coin.position.x, coin.position.y)) # the pointer
assert runner.simulate_until(lambda r: r.score > 0, max_ticks=180)
The pointer path is gated on the steer action rather than on
Input.is_mouse_button_pressed, so a tap that a button or an on-screen control
took never sends the player chasing the widget. Device queries are the escape
hatch, for when you genuinely want to know what the hardware is doing.
One discipline comes with the power: the device record Input reads is process-wide,
so a test that presses a key must release it even when the test fails. The
fixture that mounts the game handles it on the way out:
@pytest.fixture
def game():
runner = SceneRunner(screen_size=(FIELD.x, FIELD.y))
root = CoinRun(name="CoinRun")
runner.load(root)
runner.advance_frames(1)
yield runner, root
InputSimulator(runner.tree).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.
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. |
159 |
|
Three levels of headless tests for the Coin Run game in |
244 |
Source¶
1# /// simvx
2# tags = ["tutorial", "testing", "signals"]
3# web = { width = 960, height = 540, responsive = true }
4# ///
5"""Testing Your Game: a coin collector built to be tested.
6
7A tiny but complete game whose real point is the test suite beside it,
8`test_game.py`. Walk the square with WASD, the arrow keys or a controller's left
9stick, or hold the mouse button or a finger down to steer towards the pointer,
10and collect all six coins to win. `README.md` walks through the tests.
11
12## Structure for testability
13
14Testability is decided when the game is written, not when the tests are:
15
16- **The rules are pure functions.** `clamp_to_field`, `coin_positions` and
17 `within_pickup` take values and return values, so a test calls them with no
18 engine running. Each takes the field it works in as an argument rather than
19 reading a baked window size, so a test states the field it means.
20- **The world is deterministic.** Coins sit at the fixed fractions of the field
21 in `COIN_SPOTS`, so a test knows where every coin is in a window of any size.
22 A game that needs randomness should take a seed for the same reason.
23- **Events cross nodes as signals.** A `Coin` emits `collected` and removes
24 itself; the root counts it and emits `score_changed(score)`. Tests subscribe
25 to that seam instead of reaching into internals.
26
27## Three levels of tests
28
291. **Pure rules**: call the functions directly, no tree at all.
302. **Scene tree**: `SceneRunner` (from `simvx.core.testing`) mounts the real
31 `CoinRun` and ticks frames with no window, so `on_ready`, signals and
32 deferred `destroy()` all run. A test places the player on a coin and
33 asserts the score.
343. **Input-driven**: `InputSimulator` presses keys, pushes the stick and holds
35 the pointer through the real input path, so the player moves because its
36 `on_update` read the `move` action, and `runner.simulate_until` waits for
37 the outcome.
38
39## How it works
40
41`CoinRun` declares one typed `move` action (two key layouts and the left stick,
42read as a `Vec2` with `Input.value("move")`) and a `steer` button bound to
43`Pointer.PRIMARY`, the main press of any mouse, pen or finger. `score` and
44`won` are `Property` values, so the scoreboard repaints itself when they
45change. A resize moves the surviving coins and pulls the player back inside the
46new field.
47"""
48
49from simvx.core import Axis2D, Controller, Input, Node2D, Pointer, Property, Signal, Vec2
50from simvx.graphics import App
51
52MARGIN = 24.0 # the walkable field is the viewport minus this border
53PLAYER_SPEED = 300.0
54PLAYER_HALF = 14.0
55COIN_RADIUS = 10.0
56PICKUP_RADIUS = 26.0
57POINTER_DEAD_ZONE = 6.0 # stop this close to the pointer rather than jitter around it
58
59# Fractions of the field, not pixels: deterministic in a window of any size.
60COIN_SPOTS = ((0.17, 0.22), (0.50, 0.17), (0.83, 0.22), (0.17, 0.78), (0.50, 0.83), (0.83, 0.78))
61
62
63def clamp_to_field(pos: Vec2, field: Vec2) -> Vec2:
64 """Return `pos` clamped to the walkable part of a `field`-sized viewport."""
65 return pos.clamped(Vec2(MARGIN, MARGIN), field - MARGIN)
66
67
68def coin_positions(field: Vec2) -> list[Vec2]:
69 """Where the coins go in a `field`-sized viewport, in `COIN_SPOTS` order."""
70 return [Vec2(fx * field.x, fy * field.y) for fx, fy in COIN_SPOTS]
71
72
73def within_pickup(player_pos: Vec2, coin_pos: Vec2) -> bool:
74 """True when the player is close enough to `coin_pos` to collect it."""
75 return (player_pos - coin_pos).length() <= PICKUP_RADIUS
76
77
78class Player(Node2D):
79 """A square driven by the `move` action, or steered towards a held pointer."""
80
81 def on_update(self, dt: float):
82 direction = Input.value("move")
83 if direction.length() == 0.0 and Input.pressed("steer"):
84 offset = Input.mouse_position - self.position
85 if offset.length() > POINTER_DEAD_ZONE:
86 direction = offset.normalized()
87 if direction.length():
88 self.position = clamp_to_field(self.position + direction * PLAYER_SPEED * dt, self.viewport_size)
89
90 def on_draw(self, renderer):
91 renderer.rect((-PLAYER_HALF, -PLAYER_HALF), (PLAYER_HALF * 2, PLAYER_HALF * 2), fill=(0.35, 0.8, 0.95, 1.0))
92
93
94class Coin(Node2D):
95 """Announces its own collection and removes itself; it never touches the score."""
96
97 collected = Signal()
98 spot = Property(0) # index into COIN_SPOTS
99
100 def collect(self):
101 self.collected.emit()
102 self.destroy()
103
104 def on_draw(self, renderer):
105 renderer.circle((0, 0), COIN_RADIUS, fill=(1.0, 0.8, 0.2, 1.0))
106
107
108class CoinRun(Node2D):
109 score_changed = Signal(int)
110 score = Property(0)
111 won = Property(False)
112
113 input_context = {
114 "move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
115 "steer": [Pointer.PRIMARY],
116 }
117
118 def on_ready(self):
119 field = self.viewport_size
120 self.player = self.add_child(Player(name="Player", position=field / 2))
121 self.coins = [
122 self.add_child(Coin(name=f"Coin{index}", spot=index, position=spot))
123 for index, spot in enumerate(coin_positions(field))
124 ]
125 for coin in self.coins:
126 coin.collected.connect(self._on_collected)
127 self.tree.screen_resized.connect(self._relayout)
128
129 def _relayout(self, _size):
130 field = self.viewport_size
131 spots = coin_positions(field)
132 for coin in self.coins:
133 coin.position = spots[coin.spot]
134 self.player.position = clamp_to_field(self.player.position, field)
135
136 def on_update(self, dt: float):
137 for coin in list(self.coins):
138 if within_pickup(self.player.position, coin.position):
139 self.coins.remove(coin)
140 coin.collect()
141
142 def _on_collected(self):
143 self.score += 1
144 self.score_changed.emit(self.score)
145 self.won = self.score == len(COIN_SPOTS)
146
147 def on_draw(self, renderer):
148 field = self.viewport_size
149 renderer.rect((MARGIN, MARGIN), field - 2 * MARGIN, stroke=(0.3, 0.3, 0.35, 1.0), width=2)
150 renderer.text(f"Coins: {self.score}/{len(COIN_SPOTS)}", (MARGIN + 12, MARGIN + 12), fill=(1, 1, 1), scale=2)
151 if self.won:
152 renderer.text("Field clear! You win.", field / 2 - (0, 60), fill=(0.4, 1.0, 0.5), scale=3, align="centre")
153 # Example help overlay: not part of the technique shown.
154 hint = "WASD, arrows or a stick to move, or hold the pointer to steer"
155 renderer.text(hint, (MARGIN + 12, field.y - MARGIN - 24), fill=(0.5, 0.5, 0.55))
156
157
158if __name__ == "__main__":
159 App(title="Coin Run", width=960, height=540).run(CoinRun())