Recursive AI

Automated game testing

Automate game testing in C#: drive the game headless, check state, UI and screenshots, replay recorded sessions in CI and prove the simulation repeats.

Automated game testing means a program plays the game for you: it sends input or commands, advances time, and checks what the game did. The checks cover state, what the UI shows, what the screen looks like and whether a run repeats exactly. It does this headless, in CI, on every change. A practical setup has four layers: tests of the rules, scenario tests that drive the real game through its UI, replays of recorded sessions to catch regressions, and screenshot comparisons with a tolerance. This page shows all four, working, in onegame, a C# game engine where each of them is built in. Every command and output below comes from onegame 1.2.0 from NuGet, run on Linux x64 with the .NET 10 SDK.

What to automate in a game, and what to leave to people

Game test automation pays off where a check is exact and the same every time. It pays least where the question is about taste. A useful split:

LayerWhat it catchesHow it runs
Rule testsA purchase that costs the wrong amount, a refused action that still changes stateCommands sent to a headless game, then state checked
Scenario testsA button wired to the wrong command, a label that never updates, a flow that dead-endsClicks, keys and controller navigation through the real UI
Replay regressionA code change that alters what a recorded session did, at the exact tickA recording replayed while comparing a state hash every tick
Screenshot testsA layout or color change nobody meant to makeOffscreen captures compared with references at a tolerance
Determinism and multiplayerClients that drift from the server, a run that doesn’t repeatThe same script run twice, snapshots restored, server and clients compared
PeopleWhether it is fun, readable and fairPlaytests

The hard part of every automated layer is the same: you need a build of the game that runs without a window, advances time when the test says so, and gives the same result on every run. Without that, automated tests get flaky, and flaky tests get ignored.

How Unity and Unreal approach game test automation

The big engines ship frameworks for this, built around their editors:

  • Unity Test Framework “enables Unity users to test their code in both Edit Mode and Play Mode, and also on target platforms such as Standalone, Android, iOS”, and it is built on NUnit (Unity Test Framework 1.4 docs).
  • Unreal Engine’s Automation Test Framework covers unit, feature, smoke, content stress and screenshot comparison tests. It is “built directly in C++”, and its interfaces include an Automation Driver for simulating user input and a Screenshot Comparison Tool (Unreal Engine 5.8 docs). Gauntlet runs whole sessions, “a multiplayer game may require four clients and a server”, and leaves how the game performs its tests to you (Gauntlet docs).

Both give you a test runner. What neither can give you by itself is a game whose simulation is deterministic and driveable by id, so that a test written today still passes, bit for bit, next month. onegame builds that into the engine: the simulation runs in fixed ticks, randomness comes from seeded streams, and the build fails on code that would make a run unrepeatable (see deterministic game simulation in C#). The rest of this page is what that makes possible.

The game under test

A vending machine: two coins buy a can, the can drops half a second later, and buying is refused while a can is dropping, without enough credit, or when the machine is empty. Install the command and create the project:

Terminal.NET 10 SDK
dotnet tool install -g RecursiveAI.OneGame.Cli --version 1.2.0
onegame new vending
cd vending

Replace Game.cs with the machine, and delete the scaffold’s ship picture, art/. One entity class holds the synced state, the commands, the simulation step and the view, and the game definition builds the screen with ids that tests use:

Game.csonegame 1.2.0 · .NET 10
using OneGame.Core;
using OneGame.Host;
using OneGame.UI;
 
namespace Vending;
 
/// <summary>A vending machine: coins go in, a can comes out half a second after you buy it.</summary>
[Entity]
public partial class Machine : Entity
{
    public const int Price = 2;
 
    [Synced] public partial int Credit { get; private set; }
    [Synced] public partial int Stock { get; private set; }
    [Synced] public partial int Sold { get; private set; }
    [Synced] public partial int Dispensing { get; private set; }
 
    [Command]
    public void InsertCoin() => Credit++;
 
    // Refused (returns false) while a can is dropping, without enough credit, or when empty.
    [Command]
    public bool Buy()
    {
        if (Dispensing > 0 || Credit < Price || Stock == 0)
        {
            return false;
        }
 
        Credit -= Price;
        Stock--;
        Dispensing = 30;   // 30 ticks at 60 per second
        return true;
    }
 
    protected override void OnTick()
    {
        if (Dispensing > 0 && --Dispensing == 0)
        {
            Sold++;
        }
    }
 
    // Sim code for setup and fixtures; not a command, so no player can send it.
    public void Fill(int cans) => Stock = cans;
 
    [View]
    public string Status() =>
        Dispensing > 0 ? "Dispensing" : Stock == 0 ? "Sold out" : $"Credit {Credit} of {Price}";
}
 
public sealed class VendingGame : GameDefinition
{
    public override string Name => "vending";
 
    public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Vending.EntityTypes.Register(registry);
 
    public override void Setup(World world) => world.Add(new Machine()).Fill(3);
 
    public override void DefineFixtures(FixtureSet fixtures) =>
        fixtures
            .Add("last-can", world => world.Add(new Machine()).Fill(1))
            .Add("empty", world => world.Add(new Machine()));
 
    public override void DefineScreens(ScreenSet screens) =>
        screens.Add("main", game => Ui.Column(
            Ui.Label(() => MachineOf(game).Status()).Named("status"),
            Ui.Label(() => $"{MachineOf(game).Stock} left").Named("stock"),
            Ui.Row(
                Ui.Button("Insert coin", () => MachineOf(game).Send.InsertCoin()).Named("coin").Padding(Length.Px(8)).Background(Color.Rgb(0x35506E)),
                Ui.Button("Buy", () => MachineOf(game).Send.Buy()).Named("buy").Padding(Length.Px(8)).Background(Color.Rgb(0x2E6B45)))
                .Gap(Length.Px(12)))
            .Padding(Length.Px(24)).Gap(Length.Px(12)).Background(Color.Rgb(0x1C2430)).TextColor(Color.White));
 
    private static Machine MachineOf(GameInstance game) => (Machine)game.World.Entities[0];
}

Commands (InsertCoin, Buy) are the only way the world changes, and returning false refuses one. OnTick runs 60 times a second of game time. Named("buy") gives the button the id a test clicks. The two fixtures are ready-made worlds a test can start from. More on the model in Getting started.

Scenario tests: drive the real game headless

A scenario is a test written as YAML. It runs against the real game, through the same input path as a player, with no window, display or GPU needed. Delete the scaffold’s ship scenarios and their reference picture (everything in scenarios/) and add scenarios/buy-a-can.yaml:

scenarios/buy-a-can.yamlonegame 1.2.0
name: buy-a-can
description: Two coins buy a can; it drops 30 ticks later; buying without credit is refused.
seed: 1
steps:
  - expect:
      ui: { id: status, text: "Credit 0 of 2" }
  - click: buy               # no credit yet: refused
  - step: 1
  - click: coin
  - step: 1
  - click: coin
  - step: 1
  - click: buy
  - step: 1
  - expect:
      ui: { id: status, text: "Dispensing" }
  - step: 30
expect:
  - state: { entity: Machine, property: Sold, equals: 1 }
  - state: { entity: Machine, property: Stock, equals: 2 }
  - state: { entity: Machine, property: Credit, equals: 0 }
  - ui: { id: status, text: "Credit 0 of 2" }
  - ui: { id: stock, text: "2 left" }
Terminalonegame 1.2.0
onegame test --no-unit --scenario scenarios/buy-a-can.yaml
Outputonegame 1.2.0
PASS buy-a-can (36 ms, tick 34, hash a70f70b574a99ec3)
1 passed, 0 failed

--no-unit runs only the scenario, without any C# test projects (this page adds one later). The scenario clicks by id, steps frames and checks state and UI text, both between steps and at the end. The first click on Buy is refused, so the machine still has three cans when the coins go in. tick 34 and the hash are the world after the last step. With seed: 1 the hash is the same on every run and every machine, which is what makes a scenario a reliable regression test.

What a failing game test reports

Change the Stock expectation to equals: 3 and run it again:

Outputexit code 1
FAIL buy-a-can (49 ms, tick 34, hash a70f70b574a99ec3)
  expect state (line 20): Machine.Stock is 2, expected 3
    expected: 3
    actual:   2
0 passed, 1 failed

The report names the line in the YAML file, the expected value and the actual one, and onegame test exits 1. For CI, --json prints the same run as one object, with every step and expectation and the line it came from. Here it is for the second scenario, which starts from a fixture and sends commands directly, the way a network client would:

scenarios/sold-out.yamlonegame 1.2.0
name: sold-out
description: From the last can, one purchase empties the machine and the next is refused.
fixture: last-can
steps:
  - command: { entity: Machine, name: InsertCoin }
  - command: { entity: Machine, name: InsertCoin }
  - command: { entity: Machine, name: Buy }
  - step: 31
  - command: { entity: Machine, name: InsertCoin }
  - command: { entity: Machine, name: InsertCoin }
  - command: { entity: Machine, name: Buy }   # nothing left: refused, the credit stays
  - step: 1
expect:
  - state: { entity: Machine, property: Sold, equals: 1 }
  - state: { entity: Machine, property: Credit, equals: 2 }
  - ui: { id: status, text: "Sold out" }
  - ui: { selector: "Button", count: 2 }
Terminalonegame 1.2.0
onegame test --no-unit --scenario scenarios/sold-out.yaml --json
OutputJSON
{"ok":true,"scenarios":{"ok":true,"passed":1,"failed":0,"scenarios":[{"name":"sold-out","source":"/tmp/games/vending/scenarios/sold-out.yaml","passed":true,"tick":32,"hash":"9a78cf57ae0f7285","elapsedMs":35,"steps":[{"index":0,"kind":"command","line":5,"ok":true},{"index":1,"kind":"command","line":6,"ok":true},{"index":2,"kind":"command","line":7,"ok":true},{"index":3,"kind":"step","line":8,"ok":true},{"index":4,"kind":"command","line":9,"ok":true},{"index":5,"kind":"command","line":10,"ok":true},{"index":6,"kind":"command","line":11,"ok":true},{"index":7,"kind":"step","line":12,"ok":true}],"expectations":[{"index":0,"kind":"state","line":14,"passed":true,"target":"Machine.Sold","expected":"1","actual":"1"},{"index":1,"kind":"state","line":15,"passed":true,"target":"Machine.Credit","expected":"2","actual":"2"},{"index":2,"kind":"ui","line":16,"passed":true,"target":"#status","actual":"Sold out"},{"index":3,"kind":"ui","line":17,"passed":true,"target":"Button","actual":"2 match(es)"}]}]}}

Steps can also press keys, type text, navigate with a controller (nav: down, nav: activate), write a property directly with set, load a fixture or a saved snapshot mid-run, and check that a sound was played without a sound device. The full list is in Automated game testing with scenarios and Driving a game.

Screenshot tests with a tolerance

A state check can pass while the screen is wrong. A capture expectation renders the frame offscreen and compares it with a reference picture. tolerance: 2 lets each color channel differ by 2, and max-diff: 0.001 lets 0.1% of pixels differ by more than that, which absorbs small differences between GPU drivers.

scenarios/looks.yamlonegame 1.2.0
name: looks
description: The machine after one coin, compared with a reference picture.
window: 640x360
steps:
  - click: coin
  - step: 2
expect:
  - capture: { reference: refs/one-coin.png, size: 640x360, tolerance: 2, max-diff: 0.001 }

The first run has nothing to compare against. It fails and says how to create the reference:

Outputfirst run
FAIL looks (348 ms, tick 2, hash c8a6ceafe0c57ab4)
  expect capture (line 8): No reference image at /tmp/games/vending/scenarios/refs/one-coin.png. Run with --update-references (or ONEGAME_UPDATE_REFERENCES=1) to create it from the current frame, then look at it.
    expected: a reference image
    actual:   none
0 passed, 1 failed
Terminalonegame 1.2.0
onegame test --no-unit --scenario scenarios/looks.yaml --update-references
Outputonegame 1.2.0
PASS looks (321 ms, tick 2, hash c8a6ceafe0c57ab4)
created reference /tmp/games/vending/scenarios/refs/one-coin.png
1 passed, 0 failed

Look at the new reference before you commit it:

The vending machine screen after one coin: Credit 1 of 2, 3 left, an Insert coin button and a green Buy button

Now change the Buy button from green (0x2E6B45) to red (0x8A2E2E) in Game.cs and run the test again. The state hash is unchanged, because the simulation didn’t change, but the picture did:

Outputexit code 1
FAIL looks (375 ms, tick 2, hash c8a6ceafe0c57ab4)
  expect capture (line 8): The frame differs from refs/one-coin.png: 1765 px (0.766 %) differ by more than 2, max channel delta 92; allowed 0.100 %. Wrote /tmp/games/vending/artifacts/looks.0.actual.png and /tmp/games/vending/artifacts/looks.0.diff.png.
    expected: within 0.001 of reference
    actual:   1765 px differ (max delta 92)
0 passed, 1 failed

The diff image paints every pixel that differs in bright red over a darkened copy of the frame, so the change is easy to find:

The diff image: the whole Buy button is bright red over a darkened copy of the screen

When a change is intended, run with --update-references again. Captures can also be taken outside a test (onegame capture --size 1080p --after 300) or as a sequence of frames. See Screenshot tests for a game.

Record and replay: regression tests from real sessions

A recording holds the starting state and every command. Because the simulation is deterministic, replaying it rebuilds the same world tick by tick, and the replay compares the state hash at each one. Record the scenario while it runs, then replay it:

Terminalonegame 1.2.0
onegame run --headless -- --scenario scenarios/buy-a-can.yaml --record run.ogrp --artifacts .
onegame replay run.ogrp
onegame replay run.ogrp --json
Outputonegame 1.2.0
PASS buy-a-can (38 ms, tick 34, hash a70f70b574a99ec3)
1 passed, 0 failed
Outputreplay
replay identical: 34 ticks, 4 commands, final hash a70f70b574a99ec3
{"ok":true,"identical":true,"ticks":34,"commands":4,"finalHash":"a70f70b574a99ec3","segments":1}

The four commands are the refused Buy, two coins and the purchase. Now change the simulation so a can drops after 20 ticks instead of 30 (Dispensing = 20;), rebuild and replay the same file:

Outputafter the change
replay DIFFERS (segment 0 of 1): Checkpoint at tick 4 (last good 3): 1 part(s) of the state differ.
exit 1
{"ok":false,"identical":false,"ticks":34,"commands":4,"tick":4,"expected":"6984f168dbdc3452","actual":"2447a25158edbb0e","finalHash":"2447a25158edbb0e","segments":1,"segment":0}

The replay names the first tick that differs, tick 4, when the purchase ran, and exits 1. That makes a recording a regression test for game logic: keep the file, replay it in CI, and any change to what a player would have seen fails at the exact tick. A recording starts from a snapshot, so a bug caught in a long session can be replayed after the fix without replaying the whole session (Record and replay a game).

Game tests in C# with xUnit

When a test needs loops, data or assertions that YAML can’t express, the RecursiveAI.OneGame.Testing package runs the same headless game inside an xUnit test. Put the project in tests/:

tests/Vending.Tests.csprojonegame 1.2.0 · xunit.v3 4.0.2
<Project Sdk="Microsoft.NET.Sdk">
 
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
 
  <ItemGroup>
    <ProjectReference Include="../vending.csproj" />
    <PackageReference Include="RecursiveAI.OneGame.Testing" Version="1.2.0" />
    <PackageReference Include="RecursiveAI.OneGame.Live" Version="1.2.0" />
    <PackageReference Include="xunit.v3" Version="4.0.2" />
    <Using Include="Xunit" />
  </ItemGroup>
 
  <ItemGroup>
    <None Include="../scenarios/**" LinkBase="scenarios" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>
 
</Project>

The game’s own project must leave tests/ out of its compile, and a global.json selects the test runner that dotnet test --project needs on the .NET 10 SDK:

vending.csprojonegame 1.2.0
<Project Sdk="RecursiveAI.OneGame.Sdk/1.2.0">
 
  <PropertyGroup>
    <AssemblyName>Vending</AssemblyName>
    <RootNamespace>Vending</RootNamespace>
  </PropertyGroup>
 
  <ItemGroup>
    <!-- The tests are their own project. -->
    <Compile Remove="tests/**" />
  </ItemGroup>
 
</Project>
global.json.NET 10 SDK
{
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}
tests/MachineTests.csonegame 1.2.0 · .NET 10
using OneGame.Testing;
 
namespace Vending.Tests;
 
public class MachineTests
{
    private static readonly TestOptions Options = new() { Width = 640, Height = 360, Seed = 1 };
 
    // A player who feeds a coin every 20 frames and tries to buy every 50.
    private static void Customer(TestClient client, int frame)
    {
        if (frame % 20 == 0)
        {
            client.Send("Machine", "InsertCoin");
        }
 
        if (frame % 50 == 49)
        {
            client.Send("Machine", "Buy");
        }
    }
 
    [Fact]
    public void BuyingTakesTwoCoinsAndOneCan()
    {
        using var cluster = TestCluster.Start(new VendingGame(), Options);
        var client = cluster.Client;
 
        client.Click("coin").Step(1).Click("coin").Step(1).Click("buy").Step(31);
 
        Assert.Equal("0", client.Get("Machine", "Credit"));
        Assert.Equal("2", client.Get("Machine", "Stock"));
        Assert.Equal("1", client.Get("Machine", "Sold"));
    }
 
    [Theory]
    [InlineData("scenarios/buy-a-can.yaml")]
    [InlineData("scenarios/sold-out.yaml")]
    public void TheScenarioPasses(string file)
    {
        using var cluster = TestCluster.Start(new VendingGame(), Options);
        var result = cluster.Run(file);
        Assert.True(result.Passed, result.ToString());
    }
 
    [Fact]
    public void TheSameRunTwiceGivesTheSameHashEveryFrame() =>
        Determinism.RunTwice(() => new VendingGame(), Customer, 600, Options).ThrowIfDifferent();
 
    [Fact]
    public void ARecordingReplaysToTheSameHashes() =>
        Determinism.Replay(() => new VendingGame(), Customer, 600, Options).ThrowIfDifferent();
}

onegame test runs the scenarios, then every test project in the folder whose name contains “Test”:

Terminalonegame 1.2.0
onegame test
Outputonegame 1.2.0
PASS buy-a-can (50 ms, tick 34, hash a70f70b574a99ec3)
PASS looks (359 ms, tick 2, hash c8a6ceafe0c57ab4)
PASS sold-out (2 ms, tick 32, hash 9a78cf57ae0f7285)
3 passed, 0 failed
Running tests from /tmp/games/vending/tests/bin/Debug/net10.0/Vending.Tests.dll (net10.0|x64)
/tmp/games/vending/tests/bin/Debug/net10.0/Vending.Tests.dll (net10.0|x64) passed (342ms)
 
Test run summary: Passed!
  total: 5
  failed: 0
  succeeded: 5
  skipped: 0
  duration: 468ms
unit tests: 5 passed, 0 failed, 0 skipped
  • TestCluster.Start starts one headless game. Its client clicks by id, presses keys, sends commands, steps frames and reads synced properties as text.
  • cluster.Run runs a scenario file and returns its result, so the YAML tests can run under your C# test runner too.
  • Determinism.RunTwice runs a scripted player in two fresh games and compares the state hash after every frame. Determinism.Replay records the run and replays it, and SnapshotRestore checks that a snapshot restored halfway lands on the same hashes. A failure names the first frame that differs.

More in Test a game from C#.

Headless game tests in CI

Nothing above needs a display. onegame test exits 0 when everything passed and 1 when a check failed, and a build that fails exits 3, so a CI step can run it directly and archive artifacts/ for the actual and diff pictures when a screenshot test fails. Captures use the GPU when there is one and the software renderer when there isn’t. Make references on the machine that runs the tests, or loosen the tolerance for a different rasterizer.

Multiplayer tests and live control

Two more tools cover what a single process can’t:

  • Cluster scenarios start a dedicated server and several clients as separate processes over real UDP and WebSocket connections, with simulated latency and loss, then check state on each node and that every replica agrees with the server (Client/server sync with a dedicated server).
  • Live control over MCP lets a script or an MCP client drive a running Debug build with the same operations scenarios use: read state, click by id, step time, load fixtures, capture frames. That is useful for exploratory testing and for turning a found bug into a scenario (Drive a running game over MCP).

Limits at 1.2.0

  • Browser tests need an engine checkout. onegame web test runs scenarios in headless Chrome, but the web export it builds on needs an engine checkout at 1.2.0. A game made from the NuGet packages gets this:
    Outputonegame web test
    error: The web export needs the engine sources (its page and script files), so it runs for a game that sets OneGameRoot to an engine checkout (onegame new --engine <checkout>). A game on the RecursiveAI.OneGame.Sdk package cannot export for the web yet.
    Android and iOS exports need a checkout too. Desktop tests, exports and dedicated servers work from the packages.
  • No physics engine. Collision and movement are your own simulation code, so there is no physics step to test or to make deterministic.
  • Not an ECS. Entities are classes, and tests address them by type name, numeric id or Type#n.

To start, follow Getting started, then the testing recipes in the cookbook.