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.

# 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() and within_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 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=(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 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.

213

test_game.py

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

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