Recursive AI

Deterministic game simulation in C#

Make a C# game simulation deterministic with fixed-point math, seeded random streams and a tick clock, and analyzers that make floats and threads build errors.

A deterministic game simulation gives bit-identical state for the same starting state, seed and inputs, on every run and every machine. In C# that means no float or double in game state, no System.Random, no wall clock, no threads or I/O in the simulation, and no iteration over hash-ordered collections. Use fixed-point numbers, seeded random streams, a tick counter as the clock and ordered collections instead. onegame, a C# game engine, enforces all of this at build time: each of those mistakes in simulation code is a compile error. This page builds a small orbital simulation in fixed point, proves it repeats to the bit (including in a NativeAOT build), and shows the errors the analyzers give. Everything was run against onegame 1.2.0 from NuGet on Linux x64 with .NET 10.

Why C# game simulations drift

Determinism breaks quietly. The common causes, with what .NET’s own documentation says about them:

  • Floating-point math. Math.Sin “calls into the underlying C runtime, and the exact result or valid input range may differ between different operating systems or architectures” (.NET API docs). A one-bit difference in a position compounds every tick.
  • Hash-ordered collections. A HashSet<T>’s “elements are in no particular order” (HashSet docs), and for a Dictionary “the order in which the items are returned is undefined” (Dictionary docs). Two machines that loop over the same set can apply effects in a different order.
  • Unseeded or shared randomness, the clock, threads and I/O. Each brings in something that isn’t part of the game’s state, so a replay or another machine can’t reproduce it.

Determinism is what makes replays, repeatable tests, drift detection between client and server, and client-side prediction work. It is much cheaper to keep from the first line than to restore later.

Fixed-point math in C#

onegame’s Fixed is a 64-bit fixed-point number with 32 fractional bits. Every operation is integer arithmetic, so results are the same on every platform and runtime. Whole numbers convert implicitly, Fixed.FromRatio(1, 100) is one hundredth to 32 fractional bits, computed without a float, and Sqrt, Sin, Cos, Atan2 and Lerp are there. FVec2, FVec3, FQuat and Transform3 build on it. Arithmetic wraps on overflow, like unchecked integers, so keep values in range.

A comet on an orbit around a star, nudged now and then by solar wind. Gravity is computed every tick in fixed point:

Terminalonegame 1.2.0
dotnet tool install -g RecursiveAI.OneGame.Cli --version 1.2.0
onegame new comet
cd comet

Replace the scaffold’s Game.cs with the comet, and delete the ship it starts with: the art/ folder and everything in scenarios/:

Game.csonegame 1.2.0 · .NET 10
using OneGame.Core;
using OneGame.Host;
using OneGame.UI;
 
namespace Comet;
 
/// <summary>A comet on an orbit around a star at the origin, nudged now and then by solar wind.</summary>
[Entity]
public partial class Comet : Entity
{
    private static readonly Fixed Gravity = 100;   // star mass times G, in world units per tick squared
 
    [Synced] public partial FVec2 Position { get; private set; }
    [Synced] public partial FVec2 Velocity { get; private set; }
    [Synced] public partial int Orbits { get; private set; }
 
    // Sim code that a fixture or Setup calls.
    public void Launch(FVec2 position, FVec2 velocity)
    {
        Position = position;
        Velocity = velocity;
    }
 
    [Command]
    public void Burn(int percent) => Velocity = Velocity * Fixed.FromRatio(100 + percent, 100);
 
    protected override void OnTick()
    {
        // Gravity: a = -GM * p / |p|^3, all in fixed point.
        var r = Position.Length;
        var pull = Gravity / (r * r * r);
        Velocity -= Position * pull;
 
        // Once a second, a gust of solar wind from the "wind" stream: up to 0.01 units per tick either way.
        if (World!.Tick % 60 == 0)
        {
            var gust = World.Rng["wind"].NextFixed(Fixed.FromRatio(-1, 100), Fixed.FromRatio(1, 100));
            Velocity += new FVec2(gust, 0);
        }
 
        var before = Position.Y;
        Position += Velocity;
        if (before < 0 && Position.Y >= 0)
        {
            Orbits++;
        }
    }
 
    // Views may use floats: they only draw.
    [View]
    public string Label() => $"{Orbits} orbits, {Position.Length.ToDouble():0.0} from the star";
}
 
public sealed class CometGame : GameDefinition
{
    public override string Name => "comet";
 
    public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Comet.EntityTypes.Register(registry);
 
    public override void Setup(World world) => world.Add(new Comet()).Launch(new FVec2(100, 0), new FVec2(0, 1));
 
    public override void DefineScreens(ScreenSet screens) =>
        screens.Add("main", game => Ui.Column(
            Ui.Label(() => CometOf(game).Label()).Named("orbit"),
            Ui.Button("Burn +5%", () => CometOf(game).Send.Burn(5)).Named("burn").Padding(Length.Px(8)))
            .Padding(Length.Px(24)).Gap(Length.Px(12)));
 
    private static Comet CometOf(GameInstance game) => (Comet)game.World.Entities[0];
}
  • The clock is the tick. OnTick runs at a fixed 60 ticks per second of game time, and World.Tick is the only time the simulation knows. There is no deltaTime to vary between machines.
  • Randomness is a named stream. World.Rng["wind"] is seeded from the world’s seed and the stream’s name. Its draws are part of the world’s state, so a snapshot or a replay continues with the same numbers, and adding a new stream never shifts the draws of the others.
  • Views may use floats. Label converts with ToDouble() for display. Views only read state, so floats there can’t affect the simulation.

Prove it repeats

Run a minute of game time twice with seed 1, then once with seed 2:

Terminalonegame 1.2.0
onegame run --headless --frames 3600 --seed 1 --json
onegame run --headless --frames 3600 --seed 1 --json
onegame run --headless --frames 3600 --seed 2 --json
Outputonegame 1.2.0
{"ok":true,"game":"comet","headless":true,"frames":3600,"tick":3600,"hash":"fee17e8596ac0ea5","width":1280,"height":720}
 
{"ok":true,"game":"comet","headless":true,"frames":3600,"tick":3600,"hash":"fee17e8596ac0ea5","width":1280,"height":720}
 
{"ok":true,"game":"comet","headless":true,"frames":3600,"tick":3600,"hash":"f66b2a1a0533ec89","width":1280,"height":720}

hash is a hash of the whole world after the last tick. The same seed gives the same hash, and a different seed gives different wind and a different orbit. A scenario can pin the exact fixed-point state, written as decimal text:

scenarios/one-minute.yamlonegame 1.2.0
name: one-minute
description: With seed 1, a minute of flight lands on the same fixed-point state on every machine.
seed: 1
steps:
  - step: 3600
expect:
  - state: { entity: Comet, property: Orbits, equals: 5 }
  - state: { entity: Comet, property: "Position[0]", equals: 78.736828187 }
  - state: { entity: Comet, property: "Position[1]", equals: -57.234855253 }
  - ui: { id: orbit, text: "5 orbits, 97.3 from the star" }

The NativeAOT export, a different compiler and runtime from the JIT build used above, lands on the same hash:

Terminalonegame 1.2.0
onegame export --target linux
export/comet-1.0.0-linux-x64/run.sh --headless --frames 3600 --seed 1 --json
Outputonegame 1.2.0
exported comet 1.0.0 for linux-x64 (nativeaot): 5 files, 7614 KiB
  folder:  /tmp/games/comet/export/comet-1.0.0-linux-x64
  archive: /tmp/games/comet/export/comet-1.0.0-linux-x64.tar.gz (3684 KiB)
  skipped TestResults/: not a content folder (shipped folders: fonts, art, data)
  warning glibc_too_new: Comet needs glibc 2.38, newer than 2.31 in Steam's Linux runtime (and so on the Steam Deck). Build on an older system, or inside the Steam Linux Runtime SDK: see docs/exporting.md.
OutputNativeAOT build
{"ok":true,"game":"comet","headless":true,"frames":3600,"tick":3600,"hash":"fee17e8596ac0ea5","width":1280,"height":720}

(The glibc_too_new warning only matters for Steam’s Linux runtime; see Ship a game for Steam and the Steam Deck.)

Determinism tests in C#

Hash checks at the end of a run catch drift. Checks after every frame also say where it started. The RecursiveAI.OneGame.Testing package has three, run from xUnit. Set up the test project as in Test a game from C#, then:

tests/DeterminismTests.csonegame 1.2.0 · .NET 10
using OneGame.Testing;
 
namespace Comet.Tests;
 
public class DeterminismTests
{
    private static readonly TestOptions Options = new() { Seed = 1 };
 
    // A pilot who burns 5% every 400 frames.
    private static void Pilot(TestClient client, int frame)
    {
        if (frame % 400 == 399)
        {
            client.Send("Comet", "Burn", "5");
        }
    }
 
    [Fact]
    public void TheSameRunTwiceGivesTheSameHashEveryFrame() =>
        Determinism.RunTwice(() => new CometGame(), Pilot, 3600, Options).ThrowIfDifferent();
 
    [Fact]
    public void ASnapshotRestoredHalfwayFinishesOnTheSameHashes() =>
        Determinism.SnapshotRestore(() => new CometGame(), Pilot, 3600, splitFrame: 1800, Options).ThrowIfDifferent();
 
    [Fact]
    public void ARecordingReplaysToTheSameHashes() =>
        Determinism.Replay(() => new CometGame(), Pilot, 3600, Options).ThrowIfDifferent();
}
Terminalonegame 1.2.0
onegame test
Outputonegame 1.2.0
PASS one-minute (85 ms, tick 3600, hash fee17e8596ac0ea5)
1 passed, 0 failed
Running tests from /tmp/games/comet/tests/bin/Debug/net10.0/Comet.Tests.dll (net10.0|x64)
/tmp/games/comet/tests/bin/Debug/net10.0/Comet.Tests.dll (net10.0|x64) passed (523ms)
 
Test run summary: Passed!
  total: 3
  failed: 0
  succeeded: 3
  skipped: 0
  duration: 645ms
unit tests: 3 passed, 0 failed, 0 skipped

RunTwice runs the scripted pilot in two fresh games and compares the state hash after every frame. SnapshotRestore saves a snapshot at frame 1800, restores it into a new game and checks the rest of the run lands on the same hashes. Replay records the run and replays it. A failure names the first frame that differs and both hashes.

Compile-time checks: what the analyzers refuse

Tests catch non-determinism after the fact. onegame’s analyzers stop it at the build: simulation code (an entity’s OnTick, its commands, and anything marked [Sim]) may only use what is deterministic. Here are five mistakes added to the comet, with using System;, System.Collections.Generic, System.IO and System.Threading.Tasks at the top. A double synced property:

Game.cs (excerpt)onegame 1.2.0
    [Synced] public partial double Spin { get; private set; }

And, in OnTick, a float sine, a loop over a HashSet, a thread and a file read:

Game.cs (excerpt)onegame 1.2.0
        var wobble = Math.Sin(World.Tick * 0.1);
        var visited = new HashSet<int> { 3, 1, 2 };
        foreach (var sector in visited)
        {
            Orbits += sector;
        }
        Task.Run(() => Orbits++);
        var wind = File.ReadAllText("wind.txt");
Terminalonegame 1.2.0
onegame build
Outputexit code 3
/tmp/games/comet-broken/Game.cs(20,36): error OG0001: 'Spin' uses a floating-point type, which differs between platforms. Use OneGame.Core.Fixed (or FVec2, FVec3, FQuat, Transform3) for synced state.
/tmp/games/comet-broken/Game.cs(46,22): error OG0002: Floating-point value in sim code (Math.Sin(World.Tick * 0.1)). Sim code must use OneGame.Core.Fixed; floats belong in [View] code.
/tmp/games/comet-broken/Game.cs(46,31): error OG0002: Floating-point value in sim code (World.Tick * 0.1). Sim code must use OneGame.Core.Fixed; floats belong in [View] code.
/tmp/games/comet-broken/Game.cs(48,32): error OG0003: Sim code iterates in no defined order: System.Collections.Generic.HashSet<int>. Use a List<T>, an array, a SortedDictionary or SortedSet, or sort the keys first.
/tmp/games/comet-broken/Game.cs(52,9): error OG0005: Task.Run introduces threads or parallelism. Sim code runs on one thread in a fixed order.
/tmp/games/comet-broken/Game.cs(53,20): error OG0006: File.ReadAllText reads or writes outside the world. Sim code does no I/O; load data before the sim starts and pass it in as state.
build FAILED (6 errors, 0 warnings)

Every message names the rule, the expression and the fix. onegame build exits 3, and with --json the same diagnostics come as objects with code, message, file, line and column. The rules seen on this page and in the deterministic simulation recipe:

CodeRefusesUse instead
OG0001A floating-point type in synced stateFixed, FVec2, FVec3, FQuat, Transform3
OG0002A floating-point value in sim codeFixed math; floats belong in views
OG0003Iterating a collection with no defined orderA list, an array, SortedDictionary or SortedSet, or sorted keys
OG0004Reading the clock (DateTime.Now)World.Tick, or a Timed<T> value
OG0005Threads or parallelism (Task.Run)Single-threaded sim code in a fixed order
OG0006I/O (File.ReadAllText)Load data before the sim starts and pass it in as state
OG0007System.RandomWorld.Rng["name"]
OG0008A view that writes synced stateSend a command from the view

When you know better

  • Helper code outside an entity is checked when it is marked [Sim].
  • [SimSafe] vouches for one API you know is deterministic, and [SimTrust] trusts a whole namespace, type or assembly.
  • Timed<T> moves a value over a span of ticks. The authority writes it once and every node evaluates it for the tick it shows, so motion stays smooth without syncing every tick.

Deterministic lockstep, replays and server authority

In deterministic lockstep, every machine runs the whole simulation and only inputs travel, which saves bandwidth but means one non-deterministic line desyncs everyone. onegame uses determinism differently. Its multiplayer is server-authoritative: the server sends state deltas to each client’s replica, with per-connection visibility. Determinism is what lets recordings replay exactly, lets tests compare hashes, lets client-side prediction re-run commands on confirmed state, and lets client and server compare an always-on state hash, so drift is caught at the tick it happens instead of surfacing as a bug report.

Limits at 1.2.0

  • No physics engine. Collision and movement are your own fixed-point sim code, as in the orbit above.
  • Not an ECS. Entities are classes holding their own state and logic. For hot loops over many rows there are [HotData] struct-of-arrays tables.
  • Web, Android and iOS exports need an engine checkout at 1.2.0. Linux, Windows and macOS exports and dedicated servers work from the NuGet packages.

Next: Getting started, then automated game testing, which builds on everything here.