Recursive AI

Client-side prediction and rollback in C#

Client-side prediction in C#: show a player’s command at once, reconcile with the authoritative server and roll back what it refused, with working code.

Client-side prediction runs a player’s command on their own machine the moment they press the button, instead of waiting a round trip for the server. The server stays the authority. When its confirmed state arrives, the client rebuilds its predicted world from that state and runs again the commands the server hasn’t answered yet: that is server reconciliation. If the server refused a command, its effect disappears from the client’s screen: that is the rollback. In onegame, a C# game engine, you mark a command [Command(Predicted = true)], turn prediction on, and draw the predicted world. This page builds that with a dedicated server and shows a rollback happen. Everything was run against onegame 1.2.0 from NuGet on Linux x64 with .NET 10.

What this is not: onegame’s prediction is server-authoritative. It isn’t peer-to-peer input rollback of the kind GGPO popularized, where every peer runs the whole game and re-simulates when a remote input differs from its guess. The difference matters when you choose an approach, so it has its own section below.

How client-side prediction and server reconciliation work

  1. The client holds a replica of the server’s state, kept up to date by deltas. On its own, a replica shows a command’s effect only after a round trip.
  2. Prediction adds a second, writable world beside the replica. A predicted command runs on it at once, and the client draws that world. The command still goes to the server.
  3. Whenever confirmed state arrives, the predicted world is rebuilt from it, and the commands still in flight are run again on top. This is reconciliation, and it happens continuously, not only when something went wrong.
  4. If the server ran the command, the rebuilt world shows the same thing the player already saw. If the server refused it, or something else changed first, the rebuilt world doesn’t have its effect. That correction is the rollback.

Step 3 only works if running the same commands on the same state always gives the same result on the client and the server. That is why onegame’s simulation is deterministic by construction, with fixed-point numbers, seeded random streams and compile errors for floats, clocks and threads in sim code (deterministic game simulation in C#).

Prediction against a server vs peer-to-peer rollback netcode

“Rollback netcode” usually means the GGPO model. GGPO describes itself as a “networking library for peer-to-peer games”, designed “to hide network latency in fast paced, twitch style games which require very precise inputs and frame perfect execution”. Peers exchange inputs and predict the remote players’ inputs when they haven’t arrived: “If they differ, the game can be re-simulated from the point of divergence to the current visible frame” (ggpo.net). In C#, Backdash is an MIT-licensed rollback SDK whose README says it is heavily inspired by GGPO.

Server-authoritative prediction (onegame)Peer-to-peer input rollback (GGPO model)
Who decidesThe server. Clients never change confirmed state.No one. Every peer runs the same deterministic simulation.
What travelsCommands up, state deltas down, per-connection visibilityInputs between peers
What is predictedYour own commandsThe other players’ inputs
What a correction undoesYour command’s effect, when the server refused it or state changed firstFrames simulated with a wrong guess about remote input
Hidden informationPossible: hidden values are left out of the bytes a client receivesEvery peer has the whole state

If you are building a two-player fighting game over direct connections, the GGPO model is the one built for it. If you want a dedicated server that owns the game, hides what players shouldn’t see, and still lets each player’s own actions feel immediate, that is server-authoritative prediction.

Build it: a tug-of-war with a dedicated server

Two players pull a rope. Each pull moves the knot one step, and three steps from the middle wins. Create a game with a server project beside it:

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

Mark the command as predicted

The simulation lives in Sim.cs, which the game and the server both compile:

Sim.csonegame 1.2.0 · .NET 10
using OneGame.Core;
 
namespace Tug;
 
/// <summary>A tug-of-war rope. Each pull moves the knot one step; three steps from the middle wins.</summary>
[Entity]
public partial class Rope : Entity
{
    public const int Left = 1;
    public const int Right = 2;
 
    [Synced] public partial int Knot { get; private set; }
    [Synced] public partial int Winner { get; private set; }
 
    // Predicted: the client that pulls sees the knot move at once. The server still decides.
    [Command(Predicted = true)]
    public bool Pull(int side)
    {
        if (Winner != 0 || (side != Left && side != Right))
        {
            return false;   // refused: the match is over, or the side is not a side
        }
 
        Knot += side == Left ? -1 : 1;
        if (Knot <= -3)
        {
            Winner = Left;
        }
        else if (Knot >= 3)
        {
            Winner = Right;
        }
 
        return true;
    }
}

Predicted = true is the only change prediction needs in the simulation. Pull still refuses a pull after the match is over, and the server’s answer is the one that counts.

Turn prediction on and draw the predicted world

Game.csonegame 1.2.0 · .NET 10
using OneGame.Core;
using OneGame.Host;
using OneGame.Sync;
using OneGame.UI;
 
namespace Tug;
 
public sealed class TugGame : GameDefinition
{
    private Prediction? _prediction;
 
    public override string Name => "tug";
 
    public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Tug.EntityTypes.Register(registry);
 
    public override void Setup(World world) => world.Add(new Rope());
 
    // Runs every frame of a connected game: once the replica exists, put a predicted world beside it.
    public override void OnFrame(GameInstance game)
    {
        if (_prediction is null && game.Replicas.Count > 0)
        {
            var registry = new EntityRegistry();
            RegisterEntities(registry);
            var predicted = new World(game.Seed, registry, WorldRole.Predicted);
            _prediction = game.Replicas[0].EnablePrediction(predicted, new PredictionOptions());
        }
    }
 
    public override void DefineScreens(ScreenSet screens) =>
        screens.Add("main", game => Ui.Column(
            Ui.Label(() => Describe(Shown(game))).Named("knot"),
            Ui.Label(() => _prediction is null ? "offline" : $"{_prediction.CommandsPredicted} predicted, {_prediction.Reconciliations} reconciliations").Named("prediction"),
            Ui.Row(
                Ui.Button("Pull left", () => RopeIn(Shown(game)).Send.Pull(Rope.Left)).Named("left").Padding(Length.Px(8)).Background(Color.Rgb(0x2F5D8C)),
                Ui.Button("Pull right", () => RopeIn(Shown(game)).Send.Pull(Rope.Right)).Named("right").Padding(Length.Px(8)).Background(Color.Rgb(0x8C4A2F)))
                .Gap(Length.Px(12)))
            .Padding(Length.Px(24)).Gap(Length.Px(12)).Background(Color.Rgb(0x1E2228)).TextColor(Color.White));
 
    // Draw the predicted world when there is one; otherwise the game's own world (offline, or before the replica exists).
    private World Shown(GameInstance game) => _prediction?.World ?? game.World;
 
    private static string Describe(World world)
    {
        if (world.Entities.Count == 0)
        {
            return "connecting";
        }
 
        var rope = RopeIn(world);
        return rope.Winner switch
        {
            Rope.Left => "Left wins",
            Rope.Right => "Right wins",
            _ => $"Knot at {rope.Knot}",
        };
    }
 
    private static Rope RopeIn(World world) => (Rope)world.Entities[0];
}
  • OnFrame runs every frame of a game connected to a server. The predicted world gets its own registry with the same entity types as the replica.
  • EnablePrediction returns a Prediction. Reading Prediction.World is what rebuilds it when confirmed state has changed, so the screen reads it every frame through its bindings.
  • Send.Pull works from an entity of either world. The command goes to the server as usual, and because it is predicted it also runs on the predicted world at once.
  • Played alone, game.Replicas is empty, so the game draws its own world and the label says “offline”.

The scaffold’s server/Program.cs is the whole server. The only edit is the entity it seeds the world with:

server/Program.csonegame 1.2.0 · .NET 10
using Tug;
using OneGame.Server;
using OneGame.Server.Live;
 
// The whole server: listen, tick, serve live control. See server.yaml for the options (or pass them on the command line).
return ServerApp.Run(
    new ServerSetup
    {
        RegisterEntities = OneGame.Generated.TugServer.EntityTypes.Register,
        SeedWorld = (world, _) => world.Add(new Rope()),
    },
    args);

A scenario checks the rules offline, and the server exports as a NativeAOT program:

scenarios/pull-to-win.yamlonegame 1.2.0
name: pull-to-win
description: Offline, three pulls to the right win, and a pull after that is refused.
steps:
  - click: right
  - step: 1
  - click: right
  - step: 1
  - click: right
  - step: 1
  - click: left
  - step: 1
expect:
  - state: { entity: Rope, property: Knot, equals: 3 }
  - ui: { id: knot, text: "Right wins" }
Terminalonegame 1.2.0
onegame build
onegame test
onegame export --server --project server --out ./srv
Outputonegame 1.2.0
build succeeded (Debug, 0 warnings, 1.02s)
Outputonegame 1.2.0
PASS pull-to-win (34 ms, tick 4, hash b482f3324d65d05c)
1 passed, 0 failed
Outputonegame 1.2.0
exported server tug-server 1.0.0 for linux-x64 (nativeaot): 3 files, 4558 KiB
  folder:  /tmp/games/tug/srv/tug-server-1.0.0-linux-x64
  archive: /tmp/games/tug/srv/tug-server-1.0.0-linux-x64.tar.gz (2131 KiB)
  warning glibc_too_new: TugServer needs glibc 2.34; a host with an older glibc cannot run it. Build on the oldest system you deploy to.

See prediction and a rollback happen

A cluster file starts the server and two clients as separate processes. Player p1 connects over UDP with 1500 ms of simulated latency, so the gap between pressing and confirming is easy to see. Player p2 connects over WebSocket with none:

cluster-lag.yamlonegame 1.2.0
name: tug
nodes:
  - { name: hall, role: server, exe: srv/tug-server-1.0.0-linux-x64/TugServer, listen: udp+ws, scopes: [{ id: 1, seed: 7 }] }
  - { name: p1, role: client, exe: bin/Debug/net10.0/Tug, connect: hall, transport: udp, net: { latencyMs: 1500 } }
  - { name: p2, role: client, exe: bin/Debug/net10.0/Tug, connect: hall, transport: ws }
Terminalonegame 1.2.0
onegame cluster up --spec cluster-lag.yaml

That starts three processes, each with its own live-control socket, and prints their ids and addresses. Player 1 pulls left. Then read what p1 shows (every label’s text) and what it has confirmed:

Terminalonegame 1.2.0
onegame cluster call p1 ui_click '{"id":"left"}'
onegame cluster call p1 ui_query '{"selector":"Label"}' | grep '"text"'
onegame cluster call p1 state_query '{"entity":"Rope"}'
Outputonegame 1.2.0
{"focused":"Button#left","hovered":"Label","tick":655}
  "text": "Knot at -1",
  "text": "1 predicted, 391 reconciliations",
  "text": "Pull left",
  "text": "Pull right",
{"id":1,"type":"Rope","Knot":0,"Winner":0}

The screen already says “Knot at -1”, drawn from the predicted world, while the confirmed replica still holds Knot: 0. The reconciliation count is high because the predicted world is rebuilt every time confirmed state arrives, many times a second, whether or not anything was wrong.

Before p1’s pull reaches the server, player 2 pulls right three times and wins:

Terminalonegame 1.2.0
onegame cluster call p2 command_send '{"entity":"Rope","command":"Pull","args":["2"]}'
onegame cluster call p2 command_send '{"entity":"Rope","command":"Pull","args":["2"]}'
onegame cluster call p2 command_send '{"entity":"Rope","command":"Pull","args":["2"]}'
Outputonegame 1.2.0
{"entity":"Rope#1","command":"Pull","status":"Forwarded"}
{"entity":"Rope#1","command":"Pull","status":"Forwarded"}
{"entity":"Rope#1","command":"Pull","status":"Forwarded"}

A few seconds later, read p1 again and compare every replica with the server:

Terminalonegame 1.2.0
onegame cluster call p1 ui_query '{"selector":"Label"}' | grep '"text"'
onegame cluster call p1 state_query '{"entity":"Rope"}'
onegame cluster hashes
Outputonegame 1.2.0
  "text": "Right wins",
  "text": "1 predicted, 738 reconciliations",
  "text": "Pull left",
  "text": "Pull right",
{"id":1,"type":"Rope","Knot":3,"Winner":2}
p1           client scope 1  tick 1029  hash 43f81e2b6d0c4885
p2           client scope 1  tick 1029  hash 43f81e2b6d0c4885
hall         server scope 1  tick 1029  hash 43f81e2b6d0c4885
ok   p1 vs hall at tick 1029: 43f81e2b6d0c4885 / 43f81e2b6d0c4885
ok   p2 vs hall at tick 1029: 43f81e2b6d0c4885 / 43f81e2b6d0c4885

Player 1’s pull is gone. Player 2’s three pulls reached the server first and ended the match, so when p1’s pull arrived the server refused it (a pull after a win returns false). Had the server accepted it, the knot would have ended at 2 with no winner. On p1, the predicted world was rebuilt from the confirmed state, the in-flight pull was run again on a finished match and refused there too, and the screen moved from “Knot at -1” to “Right wins”. The last lines show both clients and the server on the same state hash at the same tick. onegame cluster down stops the cluster.

Tuning prediction

  • Lead. PredictionOptions.LeadTicks sets how far the predicted world runs ahead of the last confirmed tick. AutoLead, on by default, measures the lead from the ticks in which the server actually ran your commands, and MaxLeadTicks (default 60) caps it.
  • Counters. A Prediction counts CommandsPredicted, Reconciliations, Rollbacks and ResimulatedTicks. Per the API docs, Rollbacks counts reconciliations that changed what the view was showing at the same tick, so a correction that lands while the predicted world is ahead, as in the session above, appears as a reconciliation.
  • Smoothing. Simulation state is never blended. To ease a correction on screen, a view records what it draws in the Reconciling event and blends toward the corrected world after Reconciled.
  • What can be predicted. A predicted command that reads values this client doesn’t receive (hidden properties) is a compile error. One that draws random numbers is predicted only when the server sends that scope’s random streams.

Good to know

  • The command log, recordings, replays and drift hashes all come from the server and never see the predicted world, so prediction can’t corrupt a replay or hide drift.
  • The state hash is always on. A replica that drifts from the server is reported at the tick it happens, and a cluster scenario can assert that every client agrees with the server (Client/server sync with a dedicated server).
  • Nothing authenticates a player connection at 1.2.0. The cluster binds to loopback; use other machines only on a network you trust.
  • Dedicated servers export for linux-x64, from the NuGet packages. Game clients export for Linux, Windows and macOS from the packages. Web, Android and iOS exports need an engine checkout at 1.2.0 (Export for the web, Android and iOS).
  • There is no physics engine. Movement and collision are your own fixed-point sim code, which is also what gets predicted and re-run.

The recipe version of this page is Client-side prediction in the cookbook. To try it, start with Getting started.