---
title: "Deterministic game simulation in C#"
url: https://recursiveai.org/onegame/articles/deterministic-game-simulation-csharp/
description: "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."
date: 2026-10-27
author: onegame team
---

# 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.

October 27, 2026 · onegame team

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](https://recursiveai.org/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](https://learn.microsoft.com/en-us/dotnet/api/system.math.sin?view=net-10.0)). 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](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.hashset-1?view=net-10.0)), and for a `Dictionary` “the order in which the items are returned is undefined” ([Dictionary docs](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.dictionary-2?view=net-10.0)). 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](https://recursiveai.org/onegame/articles/client-side-prediction-csharp/) 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:

*Terminal · onegame 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.cs · onegame 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:

*Terminal · onegame 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
```

*Output · onegame 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.yaml · onegame 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:

*Terminal · onegame 1.2.0*

```
onegame export --target linux
export/comet-1.0.0-linux-x64/run.sh --headless --frames 3600 --seed 1 --json
```

*Output · onegame 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.
```

*Output · NativeAOT 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](https://recursiveai.org/onegame/cookbook/steam-and-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#](https://recursiveai.org/onegame/cookbook/csharp-game-tests/), then:

*tests/DeterminismTests.cs · onegame 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();
}
```

*Terminal · onegame 1.2.0*

```
onegame test
```

*Output · onegame 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");
```

*Terminal · onegame 1.2.0*

```
onegame build
```

*Output · exit 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](https://recursiveai.org/onegame/cookbook/deterministic-simulation/):

| Code | Refuses | Use instead |
|---|---|---|
| `OG0001` | A floating-point type in synced state | `Fixed`, `FVec2`, `FVec3`, `FQuat`, `Transform3` |
| `OG0002` | A floating-point value in sim code | `Fixed` math; floats belong in views |
| `OG0003` | Iterating a collection with no defined order | A list, an array, `SortedDictionary` or `SortedSet`, or sorted keys |
| `OG0004` | Reading the clock (`DateTime.Now`) | `World.Tick`, or a `Timed<T>` value |
| `OG0005` | Threads or parallelism (`Task.Run`) | Single-threaded sim code in a fixed order |
| `OG0006` | I/O (`File.ReadAllText`) | Load data before the sim starts and pass it in as state |
| `OG0007` | `System.Random` | `World.Rng["name"]` |
| `OG0008` | A view that writes synced state | Send 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](https://recursiveai.org/onegame/articles/client-side-prediction-csharp/) 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](https://recursiveai.org/onegame/docs/getting-started/), then [automated game testing](https://recursiveai.org/onegame/articles/automated-game-testing/), which builds on everything here.
