Game Simulation Testing: Deterministic Replays and AI Playthroughs

Game Simulation Testing: Deterministic Replays and AI Playthroughs

Manual playtesting can't find every bug before ship. A QA team playing 8 hours a day across 100 levels will miss edge cases that only appear after 40 hours of playtime, or that require a specific sequence of actions in a specific order. Simulation testing addresses this by running the game faster than real time, with automated inputs, and asserting on game state.

This post covers deterministic replay testing, bot-driven simulation, randomized input fuzzing, and how to run game simulations in CI.

Deterministic Replay

A deterministic game replays identically given the same initial seed and input sequence. If your game is deterministic, you can:

  1. Record a play session (inputs + timestamps)
  2. Replay it in CI
  3. Assert that the final game state matches a known-good snapshot

This catches physics changes, logic regressions, and anything that shifts the simulation.

Making Your Game Deterministic

The main sources of non-determinism:

// ❌ Non-deterministic: uses system random
float damage = Random.Range(10f, 20f);

// ✅ Deterministic: seeded random from game state
float damage = gameRng.Range(10f, 20f);  // gameRng initialized with fixed seed
// ❌ Non-deterministic: dictionary iteration order varies
foreach (var enemy in enemyDict)
{
    enemy.Value.Update();  // order undefined
}

// ✅ Deterministic: sorted list
foreach (var enemy in enemies.OrderBy(e => e.Id))
{
    enemy.Update();
}
// ❌ Non-deterministic: floating point diverges across platforms
float result = Mathf.Sqrt(position.sqrMagnitude);

// ✅ Fixed-point arithmetic for cross-platform determinism
FPVector pos = new FPVector(position);
FP result = FPMath.Sqrt(pos.sqrMagnitude);

Recording and Replaying

public class InputRecorder : MonoBehaviour
{
    private List<InputFrame> _frames = new();
    private bool _recording;

    [System.Serializable]
    public struct InputFrame
    {
        public int Tick;
        public Vector2 Movement;
        public bool Jump;
        public bool Attack;
    }

    public void StartRecording()
    {
        _frames.Clear();
        _recording = true;
    }

    public void StopRecording(string path)
    {
        _recording = false;
        var data = JsonUtility.ToJson(new RecordingData 
        { 
            Seed = GameManager.Instance.Seed,
            Frames = _frames.ToArray()
        });
        File.WriteAllText(path, data);
    }

    private void FixedUpdate()
    {
        if (!_recording) return;
        _frames.Add(new InputFrame
        {
            Tick = GameManager.Instance.CurrentTick,
            Movement = GetMovementInput(),
            Jump = Input.GetButton("Jump"),
            Attack = Input.GetButton("Attack")
        });
    }
}

public class InputReplayer : MonoBehaviour
{
    private InputFrame[] _frames;
    private int _frameIndex;

    public void LoadRecording(string path)
    {
        var data = JsonUtility.FromJson<RecordingData>(
            File.ReadAllText(path));
        _frames = data.Frames;
        GameManager.Instance.Seed = data.Seed;
    }

    public InputFrame GetFrameInput(int tick)
    {
        while (_frameIndex < _frames.Length && 
               _frames[_frameIndex].Tick < tick)
        {
            _frameIndex++;
        }
        
        if (_frameIndex < _frames.Length && _frames[_frameIndex].Tick == tick)
            return _frames[_frameIndex];
        
        return default;
    }
}

Replay Assertion Tests

[UnityTest]
public IEnumerator Replay_Level1Completion_MatchesGoldenState()
{
    // Load the golden recording (committed to repo)
    var replayer = Object.FindObjectOfType<InputReplayer>();
    replayer.LoadRecording("Assets/TestData/Recordings/Level1Complete.json");
    
    // Start the level
    SceneManager.LoadScene("Level1");
    yield return new WaitUntil(() => GameManager.Instance.IsReady);
    
    // Run until end condition or timeout
    float timeout = 120f;  // 2 minute max
    yield return new WaitUntil(() => 
        GameManager.Instance.LevelComplete || 
        Time.time > timeout);
    
    Assert.IsTrue(GameManager.Instance.LevelComplete, 
        "Level should complete within recording duration");
    
    // Assert final state matches golden snapshot
    var finalState = GameManager.Instance.GetStateSnapshot();
    var goldenState = JsonUtility.FromJson<GameStateSnapshot>(
        File.ReadAllText("Assets/TestData/Snapshots/Level1Golden.json"));
    
    Assert.AreEqual(goldenState.PlayerHealth, finalState.PlayerHealth,
        "Player health doesn't match golden state");
    Assert.AreEqual(goldenState.Score, finalState.Score,
        "Score doesn't match golden state — possible regression");
    Assert.AreApproximatelyEqual(
        goldenState.PlayerPosition.x, finalState.PlayerPosition.x, 0.01f,
        "Player position diverged — check physics changes");
}

Bot-Driven Simulation

Bots simulate player input programmatically. Unlike replay, bots can make decisions and handle variation:

public abstract class GameBot : MonoBehaviour
{
    protected PlayerController Player;
    
    public abstract void Think(GameState state);
    
    protected void SetInput(Vector2 movement, bool jump = false, bool attack = false)
    {
        Player.SetMoveInput(movement);
        if (jump) Player.Jump();
        if (attack) Player.Attack();
    }
}

public class ExplorationBot : GameBot
{
    private NavMeshAgent _agent;
    private float _nextDecisionTime;

    public override void Think(GameState state)
    {
        if (Time.time < _nextDecisionTime) return;
        
        // Move toward nearest uncollected item
        var target = state.GetNearestUncollectedItem(Player.transform.position);
        if (target != null)
        {
            _agent.SetDestination(target.transform.position);
        }
        else
        {
            // Wander randomly
            var randomPoint = GetRandomNavMeshPoint(Player.transform.position, 20f);
            _agent.SetDestination(randomPoint);
        }
        
        _nextDecisionTime = Time.time + Random.Range(0.5f, 2f);
    }
}

public class CombatBot : GameBot
{
    public override void Think(GameState state)
    {
        var nearestEnemy = state.GetNearestEnemy(Player.transform.position);
        
        if (nearestEnemy == null)
        {
            SetInput(Vector2.zero);
            return;
        }
        
        var toEnemy = (nearestEnemy.transform.position - 
                       Player.transform.position).normalized;
        float distance = Vector3.Distance(
            Player.transform.position, nearestEnemy.transform.position);
        
        if (distance > 3f)
        {
            // Move toward enemy
            SetInput(new Vector2(toEnemy.x, toEnemy.z));
        }
        else
        {
            // In range: attack
            SetInput(Vector2.zero, attack: true);
        }
    }
}

Soak Test with Bot

[UnityTest]
public IEnumerator BotPlaythrough_30Minutes_NoErrors()
{
    // Start level with exploration bot
    var bot = gameObject.AddComponent<ExplorationBot>();
    bot.Player = Object.FindObjectOfType<PlayerController>();
    
    var errorLog = new List<string>();
    Application.logMessageReceived += (msg, trace, type) =>
    {
        if (type == LogType.Error || type == LogType.Exception)
            errorLog.Add($"{msg}\n{trace}");
    };
    
    float duration = 30f * 60f;  // 30 minutes
    float startTime = Time.realtimeSinceStartup;
    
    yield return new WaitUntil(() => 
        Time.realtimeSinceStartup - startTime > duration ||
        GameManager.Instance.IsGameOver);
    
    Assert.IsEmpty(errorLog, 
        $"Bot playthrough produced {errorLog.Count} errors:\n{string.Join("\n", errorLog)}");
    Assert.IsFalse(GameManager.Instance.IsCrashed);
}

Randomized Input Testing (Fuzzing)

Fuzzing applies random or semi-random inputs to find crashes and unexpected states:

[UnityTest]
public IEnumerator RandomInputFuzz_5000Actions_NoExceptions()
{
    var player = Object.FindObjectOfType<PlayerController>();
    bool exceptionOccurred = false;
    
    Application.logMessageReceived += (msg, _, type) =>
    {
        if (type == LogType.Exception) exceptionOccurred = true;
    };
    
    var rng = new System.Random(seed: 42);  // fixed seed for reproducibility
    
    string[] actions = { "move", "jump", "attack", "dodge", 
                         "interact", "inventory", "pause", "skill1" };
    
    for (int i = 0; i < 5000; i++)
    {
        string action = actions[rng.Next(actions.Length)];
        
        switch (action)
        {
            case "move":
                var dir = new Vector2(
                    (float)(rng.NextDouble() * 2 - 1),
                    (float)(rng.NextDouble() * 2 - 1));
                player.SetMoveInput(dir);
                break;
            case "jump":
                player.Jump();
                break;
            case "attack":
                player.Attack();
                break;
            case "inventory":
                player.ToggleInventory();
                break;
        }
        
        // Advance 1-3 frames between actions
        int frames = rng.Next(1, 4);
        for (int f = 0; f < frames; f++)
            yield return null;
        
        if (exceptionOccurred) break;
    }
    
    Assert.IsFalse(exceptionOccurred, "Exception occurred during random input test");
}

State Assertion After Simulation

public static class GameStateAssert
{
    public static void AssertHealthInRange(PlayerController player, 
        float min = 0f, float max = float.MaxValue)
    {
        Assert.GreaterOrEqual(player.Health, min,
            $"Health {player.Health} below minimum {min}");
        Assert.LessOrEqual(player.Health, player.MaxHealth,
            "Health exceeds max health — healing overflow bug?");
    }
    
    public static void AssertNoPhysicsExplosions(float maxVelocity = 100f)
    {
        var rigidbodies = Object.FindObjectsOfType<Rigidbody>();
        foreach (var rb in rigidbodies)
        {
            Assert.Less(rb.velocity.magnitude, maxVelocity,
                $"{rb.name} has velocity {rb.velocity.magnitude} — physics explosion?");
        }
    }
    
    public static void AssertPlayerInBounds(PlayerController player, Bounds levelBounds)
    {
        Assert.IsTrue(levelBounds.Contains(player.transform.position),
            $"Player at {player.transform.position} is outside level bounds {levelBounds}");
    }
}

CI Integration

name: Game Simulation Tests
on:
  schedule:
    - cron: '0 2 * * *'  # nightly, not on every commit (slow)
  workflow_dispatch:

jobs:
  simulation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          lfs: true
      
      - uses: game-ci/unity-test-runner@v4
        with:
          projectPath: .
          testMode: playmode
          testFilters: YourGame.Simulation
          customParameters: -nographics -batchmode
          # Increase timeout for soak tests
          checkoutParameters: '--timeout 3600'
        env:
          UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}

Simulation tests are expensive. Run replay tests and quick bot tests on every commit; run 30-minute soak tests nightly. The nightly build failure tells you which commit introduced the regression, and you have all day to fix it before the next nightly.

Read more

Start now free