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:
- Record a play session (inputs + timestamps)
- Replay it in CI
- 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.