Recursive AI

Deterministic simulation in C#

All onegame recipes

Keep a game’s simulation deterministic with fixed-point numbers and named random streams, and let the analyzers turn floats, clocks, System.Random and view writes into compile errors.

The same game, seed and commands give the same state on every machine and every run. Replays, drift detection between client and server, and repeatable tests all depend on that. onegame enforces it at build time: sim code (an entity’s OnTick, its commands, and anything marked [Sim]) may only use what is deterministic.

Fixed-point numbers and random streams

A kite on a line, whose height moves with a gusting wind:

C#
using OneGame.Core;
using OneGame.Host;
using OneGame.UI;
 
namespace Kite;
 
/// <summary>A kite on a line: its height moves with a gusting wind.</summary>
[Entity]
public partial class Kite : Entity
{
    [Synced] public partial Fixed Height { get; private set; }
    [Synced] public partial Fixed Climb { get; private set; }
    [Synced] public partial Fixed Line { get; private set; }
 
    [Command]
    public void LetOut(int metres) => Line = Fixed.Min(Line + metres, 100);
 
    protected override void OnTick()
    {
        // Every 10 ticks a gust: a random push between -0.05 and 0.05 metres per tick, from the "wind" stream.
        if (World!.Tick % 10 == 0)
        {
            Climb += World.Rng["wind"].NextFixed(Fixed.FromRatio(-1, 20), Fixed.FromRatio(1, 20));
        }
 
        Climb = Fixed.Clamp(Climb, -1, 1);
        Height = Fixed.Clamp(Height + Climb, 0, Line);
    }
 
    // Views may use floats: they only draw.
    [View]
    public string Label() => $"Height {Height.ToSingle():0.0} m of {Line.ToInt()} m";
}
 
public sealed class KiteGame : GameDefinition
{
    public override string Name => "kite";
 
    public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Kite.EntityTypes.Register(registry);
 
    public override void Setup(World world) => world.Add(new Kite()).LetOut(30);
 
    public override void DefineScreens(ScreenSet screens) =>
        screens.Add("main", game => Ui.Column(
            Ui.Label(() => KiteOf(game).Label()).Named("height"),
            Ui.Button("Let out 10 m", () => KiteOf(game).Send.LetOut(10)).Named("let-out").Padding(Length.Px(8)))
            .Padding(Length.Px(24)).Gap(Length.Px(12)));
 
    private static Kite KiteOf(GameInstance game) => (Kite)game.World.Entities[0];
}

Run it twice with seed 1, then with seed 2:

Terminal
onegame run --headless --frames 600 --seed 1 --json
onegame run --headless --frames 600 --seed 1 --json
onegame run --headless --frames 600 --seed 2 --json
Text
{"ok":true,"game":"kite","headless":true,"frames":600,"tick":600,"hash":"84346c26e71b43aa","width":1280,"height":720}
{"ok":true,"game":"kite","headless":true,"frames":600,"tick":600,"hash":"84346c26e71b43aa","width":1280,"height":720}
{"ok":true,"game":"kite","headless":true,"frames":600,"tick":600,"hash":"8927a8ee57bb75a7","width":1280,"height":720}
  • Fixed is a 64-bit fixed-point number with 32 fractional bits. Every operation is integer arithmetic, so results are bit-identical on every platform and runtime. Arithmetic wraps on overflow, like unchecked integers. Whole numbers convert implicitly (Line + metres). Fixed.FromRatio(-1, 20) is −0.05 without a float in sight. Sqrt, Sin, Cos, Atan2 and Lerp are there too, and FVec2, FVec3, FQuat and Transform3 build on it.
  • World.Rng["wind"] is a named stream seeded from the world’s seed and its name. Its draws are part of the world’s state, so a replay or a snapshot carries on with the same numbers, and adding a stream never disturbs the others.
  • World.Tick is the sim’s clock. Sim code never reads the wall clock.
  • Views convert for display with ToSingle() or ToDouble(). Those two are for views and rendering only.

A scenario can check Fixed values exactly, as decimal text:

YAML
name: gusts
description: With seed 1 the kite's flight is the same on every machine, to the last bit.
seed: 1
steps:
  - step: 100
expect:
  - state: { entity: Kite, property: Height, equals: 2.92985297 }
  - state: { entity: Kite, property: Climb, equals: 0.026807537 }
  - ui: { id: height, text: "Height 2.9 m of 30 m" }
Text
PASS gusts (31 ms, tick 100, hash 00af5f4666d6effb)
1 passed, 0 failed

What the analyzers refuse

The rules are compile errors, so a non-deterministic line can’t reach a test. With using System; added, here are four mistakes in the apple tree from Getting started: a float synced property, System.Random and the clock in OnTick, and a view that writes state.

C#
    [Synced] public partial float Speed { get; private set; }
 
    protected override void OnTick()
    {
        // ...
        Apples += new Random().Next(3);
        float rate = DateTime.Now.Second / 60f;
    }
 
    [View]
    public string Label()
    {
        Picked = 0;
        return $"{Apples} on the tree, {Picked} picked";
    }
Terminal
onegame build
Text
/tmp/games/broken/Game.cs(15,35): error OG0001: 'Speed' uses a floating-point type, which differs between platforms. Use OneGame.Core.Fixed (or FVec2, FVec3, FQuat, Transform3) for synced state.
/tmp/games/broken/Game.cs(40,19): error OG0007: Random.Next is not a deterministic named stream. Use world.Rng["name"] so draws are part of the world state.
/tmp/games/broken/Game.cs(40,19): error OG0007: new Random is not a deterministic named stream. Use world.Rng["name"] so draws are part of the world state.
/tmp/games/broken/Game.cs(47,9): error OG0008: View code cannot change simulation state: it assigns the synced property 'Picked'. Views read state and send commands; use entity.Send.<Command>(...) instead.
/tmp/games/broken/Game.cs(41,22): error OG0002: Floating-point value in sim code (DateTime.Now.Second / 60f). Sim code must use OneGame.Core.Fixed; floats belong in [View] code.
/tmp/games/broken/Game.cs(41,22): error OG0004: DateTime.Now reads the clock, but the sim advances only by ticks. Use World.Tick, or a Timed<T> value.
build FAILED (6 errors, 0 warnings)

onegame build exits 3. With --json the same diagnostics come as objects with code, message, file, line and column. Threads, I/O and iterating a hash-ordered collection in sim code are refused the same way.

Good to know

  • Helper code outside an entity is checked when it is marked [Sim]. [SimSafe] vouches for an 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.
  • To prove a game is deterministic, Determinism.RunTwice, SnapshotRestore and Replay compare the hash after every frame: see Test a game from C#.
  • The state hash is also how client and server find drift: see Client/server sync with a dedicated server.

A longer guide: Deterministic game simulation in C#.