# Testing Your Game a coin collector built to be tested. ```{raw} html ▶ 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. ```bash # 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: ```python 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: ```python 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: ```python 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: ```python @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: ```bash 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`](#source) | Testing Your Game: a coin collector built to be tested. | 213 | | [`test_game.py`](tutorials_testing_your_game--test_game-py.md) | Three levels of headless tests for the Coin Run game in `main.py`. | 160 | ```{toctree} :hidden: tutorials_testing_your_game--test_game-py ``` ## Source ```{literalinclude} ../../examples/tutorials/testing_your_game/main.py :language: python :linenos: ```