Show a player’s command on their own screen at once while the server decides, with predicted commands, a predicted world beside the replica, and rollback when the server disagrees.
A client’s replica is exactly the server’s state, so a command’s effect shows only after a round trip. Prediction adds a second, writable world beside the replica. The client runs predicted commands on it at once and draws it instead. When confirmed state arrives, the predicted world is rebuilt from it and the commands still in flight are run again. If the server refused one, its effect disappears: that is the rollback.
This builds on the game from Client/server sync with a dedicated server.
Mark the command
In Sim.cs:
using OneGame.Core; namespace Harbor; /// <summary>The state of the game: one synced number and a command that changes it.</summary> [Entity] public partial class Counter : Entity { [Synced] public partial int Count { get; private set; } // Predicted: a client runs it on its predicted world at once; the server still decides. [Command(Predicted = true)] public void Increment(int by) => Count += by; }
Turn prediction on and draw the predicted world
In Game.cs:
using OneGame.Core; using OneGame.Host; using OneGame.Sync; using OneGame.UI; namespace Harbor; public sealed class HarborGame : GameDefinition { private Prediction? _prediction; public override string Name => "harbor"; public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Harbor.EntityTypes.Register(registry); public override void Setup(World world) => world.Add(new Counter()); // Runs every frame of a connected game, after the network was pumped: turn prediction on once the replica exists. 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(() => Shown(game).Entities.Count > 0 ? "Count: " + CounterIn(Shown(game)).Count : "connecting").Named("count"), Ui.Button("Add one", () => CounterIn(Shown(game)).Send.Increment(1)).Named("add").Padding(Length.Px(8)).Background(Color.Rgb(0x3060A0))) .Padding(Length.Px(24)).Gap(Length.Px(12)).Background(Color.Rgb(0x203040)).TextColor(Color.White)); // Draw the predicted world when there is one, else the game's own (single player, or the confirmed replica). private World Shown(GameInstance game) => _prediction?.World ?? game.World; private static Counter CounterIn(World world) => (Counter)world.Entities[0]; }
OnFrameruns once per frame of a game connected to a server. The predicted world needs the same entity types as the replica, so it gets its own registry filled byRegisterEntities.EnablePredictionreturns aPrediction. ReadingPrediction.Worldis what rebuilds it when confirmed state has changed, so read it from the frame, as the view does here.Send.Incrementworks from an entity of either world. The command goes to the server exactly as before, and a predicted command also runs on the predicted world at once.- Played alone (no server),
game.Replicasis empty, so the game draws its own world and nothing changes.
See it
To make the gap easy to see, give p1 a 1500 ms link in cluster-lag.yaml:
name: harbor nodes: - { name: dock, role: server, exe: srv/harbor-server-1.0.0-linux-x64/HarborServer, listen: udp+ws, scopes: [{ id: 1, seed: 7 }] } - { name: p1, role: client, exe: bin/Debug/net10.0/Harbor, connect: dock, transport: udp, net: { latencyMs: 1500 } } - { name: p2, role: client, exe: bin/Debug/net10.0/Harbor, connect: dock, transport: ws }
Rebuild the game and the server, start the cluster, click on p1, then read what it shows and what it has confirmed:
onegame build onegame export --server --project server --out ./srv onegame cluster up --spec cluster-lag.yaml onegame cluster call p1 ui_click '{"id":"add"}' onegame cluster call p1 ui_query '{"selector":"#count"}' onegame cluster call p1 state_query '{"entity":"Counter"}'
{"focused":"Button#add","hovered":"Label","tick":348} [{ "kind": "Label", "id": "count", "text": "Count: 1", ... }] {"id":1,"type":"Counter","Count":0}
The label already says 1, drawn from the predicted world, while the confirmed replica still holds 0. Three seconds later the server has run the command and both clients and the server agree:
{"id":1,"type":"Counter","Count":1} {"id":1,"type":"Counter","Count":1} p1 client scope 1 tick 554 hash 110ca69128e29505 p2 client scope 1 tick 554 hash 110ca69128e29505 dock server scope 1 tick 554 hash 110ca69128e29505 ok p1 vs dock at tick 554: 110ca69128e29505 / 110ca69128e29505 ok p2 vs dock at tick 554: 110ca69128e29505 / 110ca69128e29505
The first two lines are state_query on p1 and on p2; the rest is onegame cluster hashes.
Good to know
- The server stays the authority. The command log, recordings, replays and drift hashes come from the server and never see the predicted world.
PredictionOptions.LeadTickssets how far the predicted clock runs ahead of the confirmed one, andAutoLead(on by default) measures it from the round trip. ThePredictioncountsCommandsPredicted,Reconciliations,RollbacksandResimulatedTicks.- A predicted command that needs values this client doesn’t receive (hidden properties) is a compile error. One that draws random numbers is predicted only when the scope sends its random streams.
- Sim state is never blended. To smooth a correction, a view can use the
ReconcilingandReconciledevents to ease what it draws. - This is client-side prediction against a server, with reconciliation. It isn’t peer-to-peer input rollback of the kind fighting games use.
A longer guide: Client-side prediction in C#.
How this compares with LiteNetLib, LiteEntitySystem and Photon Fusion: C# multiplayer networking.