Testing Your Game

a coin collector built to be tested.

▶ Run in browser

Tags: 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() and within_pickup() take values and return values. They can be tested with no engine running. Note what clamp_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 emits score_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 in tests/, 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

main.py

Testing Your Game: a coin collector built to be tested.

159

test_game.py

Three levels of headless tests for the Coin Run game in main.py.

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())