Prediction and reconciliation
Predict an owned entity, reconcile it with worker state, drive server-owned entities and vehicles, and perform lag-compensated queries.
Use PredictedBehaviour<TInput> for an entity that must respond to local input before a worker reply arrives. A player controller is the common example; a vehicle the player drives is another (see Drive a vehicle).
Keep NetworkIdentity and an enabled NetworkTransform on the controller's root. A newly added predicted behavior requires and adds the transform component; check existing prefabs explicitly. Use worker authority (Authority = Server) so remote clients and ghost workers follow the worker's state. The owning client uses reconciliation. Do not combine this controller with NetworkRigidbody.
The owning client collects one input value per tick and simulates it immediately. The controlling worker simulates the same input and sends its result back. If the two positions differ by more than CorrectionThreshold, the client applies the worker state and repeats its later inputs. Nebula calls this process reconciliation.
Define the input
Create a struct that implements INetworkInput. Implement Serialize and Deserialize in the same field order.
using Nebula;
using UnityEngine;
public struct PlayerInput : INetworkInput
{
public Vector2 Move;
public float Yaw;
public bool Jump;
public void Serialize(NetworkWriter writer)
{
writer.WriteVector2(Move);
writer.WriteFloat(Yaw);
writer.WriteBool(Jump);
}
public void Deserialize(NetworkReader reader)
{
Move = reader.ReadVector2();
Yaw = reader.ReadFloat();
Jump = reader.ReadBool();
}
}Send player intent rather than the resulting transform. For example, send movement axes and a jump button instead of a world position. Keep the struct small because the client sends it every tick.
Implement the predicted behavior
Derive the controller from PredictedBehaviour<PlayerInput>. Implement GatherInput and Simulate.
using Nebula;
using UnityEngine;
using UnityEngine.InputSystem;
public sealed class PlayerController : PredictedBehaviour<PlayerInput>
{
public float Speed = 6f;
private float _yaw;
protected override PlayerInput GatherInput()
{
var keyboard = Keyboard.current;
var move = Vector2.zero;
if (keyboard != null)
{
move.x = (keyboard.dKey.isPressed ? 1f : 0f) -
(keyboard.aKey.isPressed ? 1f : 0f);
move.y = (keyboard.wKey.isPressed ? 1f : 0f) -
(keyboard.sKey.isPressed ? 1f : 0f);
}
return new PlayerInput
{
Move = Vector2.ClampMagnitude(move, 1f),
Yaw = _yaw,
Jump = keyboard != null && keyboard.spaceKey.isPressed,
};
}
protected override void Simulate(uint tick, in PlayerInput input, float deltaTime)
{
transform.rotation = Quaternion.Euler(0f, input.Yaw, 0f);
var velocity =
(transform.right * input.Move.x + transform.forward * input.Move.y) * Speed;
transform.position += velocity * deltaTime;
Identity.Motion.Velocity = velocity;
}
}Nebula calls GatherInput only on the owning client. It calls Simulate on the owning client, the authoritative worker, and again on the client during a replay.
Write Simulate as a deterministic update from the current state and input. Do not read live keyboard state, use Time.deltaTime, or make an unseeded random choice inside it. Use the supplied deltaTime.
Set Identity.Motion.Velocity when you move without a NetworkRigidbody. Remote interpolation and handover use this value.
Handle one-time effects
A replay can call Simulate more than once for the same tick. Check IsReplaying before you play a sound, spawn a visual effect, or perform another effect that should happen only once.
Keep authoritative gameplay changes behind HasAuthority. The predicted client never has worker authority, even when it owns the entity.
For a fire button, store enough state in the input or controller to recognize one press. Consider using an increasing sequence number for an action that must survive a missing input packet. A repeated boolean can lose an edge when the worker repeats its previous input.
Choose reconciliation state
By default, WriteState records the entity's container-local position and rotation plus Identity.Motion.Velocity. ReadState restores those values.
Override both methods when the next simulation result also depends on other state, such as grounded state, a movement mode, or a cooldown. Include everything Simulate carries from one tick to the next: the same state moves with the entity when another worker takes it over.
protected override void WriteState(NetworkWriter writer)
{
base.WriteState(writer);
writer.WriteBool(_grounded);
writer.WriteFloat(_cooldown);
}
protected override void ReadState(NetworkReader reader)
{
base.ReadState(reader);
_grounded = reader.ReadBool();
_cooldown = reader.ReadFloat();
}Read fields in the same order and with the same types used to write them. Call the base implementation first if you want to keep the default pose and velocity state.
Override CorrectionThreshold to change the position error that triggers a correction. The default is 0.05 meters, and it grows at speed (see Scale corrections with speed). Use OnCorrected(float magnitude) for diagnostics or a visual response after a correction.
Smooth corrections
A correction moves the simulated state to the worker's state at once. Without smoothing, the entity would jump on screen by the whole error. A shove from the worker at 100 ms round-trip time (RTT) and 9 m/s is a 0.9 m jump.
Nebula keeps the simulated state exact and draws the entity at an offset from it instead. When a correction happens:
- The offset starts as the pose the entity was drawn at, minus the corrected pose, so the entity does not move on screen that frame.
- The offset shrinks exponentially toward zero. By default it falls to 1% of its size after 0.1 seconds. Position and rotation both ease.
- A correction that arrives while an offset is still shrinking adds to it.
- The offset is stored in the local space of the entity's container. It follows a ship or other moving container, and it stays correct if the entity changes container.
- If the new offset would be longer than 3 meters, or when the entity goes through a portal, the entity teleports and no offset is kept.
Only the drawn pose carries the offset. Simulate, WriteState, the replay, and the history Nebula keeps never see it, so prediction stays exact. Nebula moves the entity's transform to the drawn pose at the end of each client frame and back to the simulated pose before it simulates or reconciles again. Code that runs later in the frame, such as a camera in LateUpdate, therefore reads transform.position and transform.rotation as the drawn pose.
To read the drawn pose at any time, including from inside Simulate or OnCorrected, use PredictedBehaviourBase.DrawnPosition and DrawnRotation. A camera that follows the entity should use one of the two. Do not cache transform.position across frames for this.
Override these properties on your behavior to tune the smoothing:
protected override float CorrectionSmoothingSeconds => 0.15f; // 0 turns smoothing off
protected override float CorrectionSnapDistance => 2f; // metersSmoothing applies on the owning client, and on a client that drives a vehicle. It never changes what the worker or other clients see.
Scale corrections with speed
Fixed thresholds suit a walking character, not a vehicle. At 400 m/s an entity travels 6.7 m in one tick: a 5 cm threshold replays on errors no player can see, and a 3 m correction is only 7 ms of travel but would teleport. Nebula therefore scales three values with the entity's speed in the worker's report:
| Value | Rule | Default factor | At 5 m/s | At 400 m/s | At 3,000 m/s |
|---|---|---|---|---|---|
| Correction threshold | The larger of CorrectionThreshold and this fraction of one tick's travel | CorrectionThresholdTickFraction = 0.25 | 0.05 m | 1.67 m | 12.5 m |
| Snap distance | The larger of CorrectionSnapDistance and this many seconds of travel | CorrectionSnapSeconds = 0.25 | 3 m | 100 m | 750 m |
| Smoothing time | CorrectionSmoothingSeconds × (1 + speed / this), at most three times it | CorrectionSmoothingDoublingSpeed = 1500 m/s | 0.1 s | 0.127 s | 0.3 s |
The speed is CorrectionSpeed, which by default is the magnitude of Identity.Motion.Velocity in the reported state. The default WriteState carries that velocity, so a behavior that keeps Motion.Velocity up to date in Simulate gets the scaling without other changes. Below about 12 m/s nothing changes.
Each factor is a protected virtual property. Set one to 0 to make its value fixed, or override CorrectionSpeed if your state keeps the velocity somewhere else:
protected override float CorrectionThresholdTickFraction => 0.1f; // replay sooner at speed
protected override float CorrectionSnapSeconds => 0f; // always snap beyond CorrectionSnapDistanceA larger threshold lets a real divergence persist longer before it is corrected. CorrectionThresholdAt(speed) returns the threshold in use, and LastCorrectionSpeed the speed of the most recent reconcile.
Draw a predicted entity between ticks
Prediction runs in fixed steps of 1/60 second. A predicted entity drawn at its newest tick therefore moves in 16.7 ms steps, whatever the frame rate. A fast vehicle shows it most: at 144 frames per second, about three frames show one pose and the next jumps a whole tick, and a frame that runs two fixed steps jumps two.
To draw the entity smoothly, override RenderBetweenTicks:
protected override bool RenderBetweenTicks => true;Each frame, Nebula then draws the entity between the poses of its last two predicted ticks, as far along as the frame is into the current fixed step. Rotation is blended the same way. This is what Unity's RigidbodyInterpolation.Interpolate does, done by Nebula because Nebula steps a predicted body itself. Leave the Rigidbody's own interpolation off.
The blend is part of the drawn pose described in Smooth corrections:
Simulate,WriteState, the replay and the history never see it.- A camera that follows
transforminLateUpdate, or readsDrawnPositionandDrawnRotation, sees the blended pose. - Nothing is blended across a change of container, a move made outside
Simulate(a teleport), or a step longer than bothCorrectionSnapDistanceand two ticks of travel atIdentity.Motion.Velocity. - After a correction, the blend follows the replayed ticks, and the correction offset still starts where the entity was drawn.
The cost is up to one tick of visual delay on the entity: 8 ms on average at 60 ticks per second. The setting is off by default, so a game that already blends its own view between ticks keeps doing so. RenderTickFraction reports the fraction used for the last frame.
Apply server events
Simulate must depend only on the entity's state and its input, because the owning client runs it ahead of the worker and again during a replay. Something only the worker decides, such as a shove, a stun, or a forced move, breaks that rule if you apply it from outside Simulate. The owner's replay does not know why the worker's state changed, so any state outside WriteState drifts, and a one-time effect cannot tell a replay from the first run.
A server event puts that decision inside the simulation. The worker attaches a small struct to a tick of the entity. Nebula shows it to Simulate on that tick on the worker, sends it to the owner with the state reports, and shows it again in the owner's replay of the same tick.
Define the event as a struct that implements INetworkSerializable:
using Nebula;
using UnityEngine;
public struct Impulse : INetworkSerializable
{
public Vector3 Velocity;
public void Serialize(NetworkWriter writer) => writer.WriteVector3(Velocity);
public void Deserialize(NetworkReader reader) => Velocity = reader.ReadVector3();
}Read it inside Simulate with TryGetServerEvent, and apply it to state that WriteState carries. This example adds to a velocity field that the controller also writes and reads in WriteState and ReadState:
protected override void Simulate(uint tick, in PlayerInput input, float deltaTime)
{
if (TryGetServerEvent<Impulse>(out var impulse))
_knockback += impulse.Velocity;
// ... the rest of the movement, using _knockback ...
}Post the event on the worker that has authority over the entity. The call is available on every predicted behavior, so code that does not know the controller's type can use identity.Predicted:
// On the worker, for example in the code that resolves an explosion.
pawn.Predicted.PostServerEvent(new Impulse { Velocity = direction * 9f });PostServerEvent<TEvent>(in TEvent e) stamps the event with the next tick the entity simulates and returns true. It returns false and logs a warning once on a copy without authority.
Know when an event applies
- The next tick the entity simulates is the tick in progress when the entity has not run yet in this worker tick, and the following tick otherwise. It does not matter whether your code runs before or after the entity's own tick.
TryGetServerEvent<TEvent>(out e)returns the first event of that type for the tick being simulated. UseTryGetServerEvent<TEvent>(index, out e)for the next ones, in posting order. It finds nothing outsideSimulate.- If the worker posts the event after the entity's tick, the report for that tick already carries the event for the next one. The owner then replays from that state with the event on its tick, and its simulated state ends where the worker's does. Nebula counts these replays in
ServerEventReplaysand does not count them as corrections. - If the worker posts the event before the entity's tick, the event and its effect arrive together. The owner is corrected once, by the size of the effect over the time the report took to arrive, and correction smoothing eases the drawn pose. The simulated state is still exact.
- A worker keeps reporting an event for 30 ticks (
ServerEventRepeatTicks) so that a lost packet does not lose it. The owner applies it once. - When another worker takes over the entity, the events it has not shown to
Simulatetravel with the pending inputs. The new worker shows each one once.
Stay within the limits
Each entity accepts at most four events for one tick (MaxServerEventsPerTick). PostServerEvent refuses the fifth, returns false, and adds one to ServerEventsDropped. ServerEventsPosted counts the accepted ones.
Nebula identifies the event type on the wire by a hash of its full name, so use one struct for each kind of event and do not rename it between builds of a mesh. A server event only changes the entity it is posted to. An entity with no owning client and no driver receives events on the worker, and no client replays them.
A client built against an earlier wire protocol (28) is not sent the events and predicts without them, which gives the correction behavior it had before. See the wire protocol specification.
Understand input timing
The client estimates the worker tick from snapshots and round-trip time. It sends input ahead by half the measured round-trip time, InputLeadMarginTicks, and an adaptive adjustment.
The worker reports the smallest lead it observed. The client tries to keep that value near InputLeadTargetTicks. It increases the adaptive adjustment when input arrives too late and slowly reduces it when the margin is larger than needed. Limit this adjustment with InputLeadMaxAdjustTicks.
If the expected input is missing, the worker repeats its most recent input instead of freezing the entity. It increments InputsMissed. If no input has arrived since spawn, it uses the default value of the input struct.
Recover when the worker falls behind
A worker that falls more than NebulaWorker.MaxLagTicks (15 ticks) behind the wall clock skips tick numbers instead of simulating them back to back. By default, the worker then tells the owner which ticks it dropped for the entity (protocol 31), and the owner re-bases: it takes the worker's state on the next report as its new base and predicts on from it with the inputs after it. This is not counted as a misprediction (Corrections), and it isn't eased. The entity moves at once by what the dropped inputs had moved it, the real divergence. Rebases and MaxRebaseMagnitude count these on the owner, and TicksDropped counts the dropped ticks on the worker. A client of protocol 30, or a worker with NebulaConfig.ReportDroppedTicks off, counts a correction instead.
To avoid the correction, override StepSkippedServerTicks to return true. When the entity next ticks, the worker then runs Simulate for each skipped tick that has an input waiting, oldest first, and then for the current tick. The entity ends where its owner predicted it. SkippedTicksStepped counts these extra steps.
This is opt-in because it only suits an entity whose Simulate advances the entity itself and depends only on its state and the input, as replay already requires. If Simulate applies forces or sets the velocity of a Rigidbody and relies on Unity stepping physics between ticks, the skipped ticks would apply their forces back to back before one physics step and the entity would lurch. Leave StepSkippedServerTicks at false for those entities. They re-base after a skip.
A worker that keeps skipping, twice within NebulaWorker.SustainedSkipTicks (a second at 60 Hz), is overloaded rather than past a hitch. Stepping every entity through the ticks it couldn't afford would only push it further behind, so it steps none of them while that lasts (noreplay on its profile line), and their owners re-base too.
Use these values to diagnose timing:
PendingInputCountis the number of future inputs buffered on the worker.InputsMissedcounts ticks that repeated the previous input.InputLeadMinis the smallest lead observed during the reporting window.LastProcessedInputTickis the newest input tick the worker simulated.ProcessedInputThisTickreports whether the worker used a newly received input.
See Debugging for the client-side lead values.
React to an input when it arrives
An input reaches the worker a few ticks before the tick it belongs to runs. To act on it earlier, such as sending the owner a provisional acknowledgement of a jump, override OnServerInputReceived:
protected override void OnServerInputReceived(uint tick, in PlayerInput input)
{
if (input.Jump)
SendJumpAcknowledgement(tick); // your own message to the owner; changes no simulated state
}The worker with authority calls it once for each input, the first time the input arrives and before Simulate runs for that tick. It is not called for:
- A repeat of an input the worker already has, such as a redundant resend.
- An input that arrives after its tick has run.
- Inputs that arrive with a handover. The previous worker already received them.
- A copy of the entity that does not have authority, or on a client.
Treat the hook as read-only. The input has not been simulated yet, so do not change state that Simulate reads or that the owner reconciles. Make state changes in Simulate. If the override throws, Nebula logs the error and handles the input as usual.
Transfer prediction during handover
When an entity moves to another worker, the authority transfer includes the last processed input, future buffered inputs, the previous input used for repetition, and the state WriteState writes. The receiving worker applies that state with ReadState, so it continues on the next tick from exactly where the previous worker stopped, for example in the middle of a jump, without asking the client to resend its history.
The client keeps the same network ID and continues predicting. Owner-state messages from the new worker carry a higher authority epoch, so the client rejects older state from the previous worker.
If your simulation depends on state outside WriteState, NetworkVariable, or another replicated component, transfer it with WriteHandoverState and ReadHandoverState. See Physics and handover state.
Drive an entity without a client
Call NebulaWorker.SpawnServerDriven to spawn an entity with no owning client. Override GatherServerInput(uint tick) to provide its input on the controlling worker.
protected override PlayerInput GatherServerInput(uint tick)
{
return new PlayerInput
{
Move = ChooseMovement(tick),
Yaw = ChooseYaw(tick),
};
}The worker calls the same Simulate method used for a player. A server-driven entity can therefore move between workers through the normal handover path.
Store behavior state that must survive handover in a NetworkVariable or the handover-state methods. Rebuild worker-local state in OnGainedAuthority.
Drive a vehicle
A vehicle that players drive, such as a car or a boat, is an entity no client owns: several players can ride in it, and it stays in the world when its driver leaves. Spawn it with NebulaWorker.SpawnServerDriven and give it a PredictedBehaviour<TInput>. When a player takes the driving seat, the worker hands that player's client the controls with NetworkIdentity.SetDriver. From then on the vehicle runs the driver's input on the worker, and the driver's client predicts it and reconciles it with the worker's state, as it does for its own pawn.
Without prediction, the driver sees the vehicle respond to the wheel a round trip plus about 110 ms later, because the worker's result reaches the client through the same interpolation delay as any other entity. On a 30 ms round-trip time (RTT) that is 140 ms; on a 150 ms RTT it is 273 ms, and a driver who steers by what they see turns past the heading they wanted. With prediction, the vehicle responds on the next frame at any latency. Other clients still see the vehicle interpolated.
Write the vehicle
The vehicle's behavior is an ordinary predicted behavior. GatherInput reads the local player's controls, GatherServerInput says what the vehicle does with nobody at the wheel, and Simulate moves it.
using Nebula;
using UnityEngine;
using UnityEngine.InputSystem;
public struct VehicleInput : INetworkInput
{
public float Throttle;
public float Steer;
public void Serialize(NetworkWriter writer)
{
writer.WriteFloat(Throttle);
writer.WriteFloat(Steer);
}
public void Deserialize(NetworkReader reader)
{
Throttle = reader.ReadFloat();
Steer = reader.ReadFloat();
}
}
public sealed class VehicleController : PredictedBehaviour<VehicleInput>
{
public float Acceleration = 8f;
public float Drag = 0.4f;
public float TurnRate = 60f;
private float _speed;
protected override VehicleInput GatherInput()
{
var keyboard = Keyboard.current;
if (keyboard == null) return default;
return new VehicleInput
{
Throttle = (keyboard.wKey.isPressed ? 1f : 0f) - (keyboard.sKey.isPressed ? 1f : 0f),
Steer = (keyboard.dKey.isPressed ? 1f : 0f) - (keyboard.aKey.isPressed ? 1f : 0f),
};
}
// Nobody at the wheel: coast to a stop.
protected override VehicleInput GatherServerInput(uint tick) => default;
protected override void Simulate(uint tick, in VehicleInput input, float deltaTime)
{
_speed += (input.Throttle * Acceleration - Drag * _speed) * deltaTime;
float yaw = input.Steer * TurnRate * Mathf.Clamp01(Mathf.Abs(_speed) / 4f) * deltaTime;
var rotation = Identity.LocalRotation * Quaternion.Euler(0f, yaw, 0f);
var velocity = rotation * Vector3.forward * _speed;
Identity.SetLocalPose(Identity.Container, Identity.LocalPosition + velocity * deltaTime, rotation);
Identity.Motion.Velocity = Identity.Container != null ? Identity.Container.Rotation * velocity : velocity;
}
protected override void WriteState(NetworkWriter writer)
{
base.WriteState(writer);
writer.WriteFloat(_speed);
}
protected override void ReadState(NetworkReader reader)
{
base.ReadState(reader);
_speed = reader.ReadFloat();
}
}Keep Simulate deterministic and kinematic, as for a player controller: the client runs it again for a single vehicle during a replay, which PhysX cannot do for a Rigidbody. Do not use NetworkRigidbody or wheel colliders on a predicted vehicle. Write everything Simulate carries from tick to tick, such as the speed here, in WriteState and ReadState.
Hand the controls to a player
Call SetDriver on the worker that has authority over the vehicle when a player's pawn takes the driving seat, and ClearDriver when it leaves:
// On the worker, in your seat logic.
void OnDriverSeated(NetworkIdentity vehicle, NetworkIdentity pawn) => vehicle.SetDriver(pawn.OwnerClientId);
void OnDriverLeft(NetworkIdentity vehicle) => vehicle.ClearDriver();SetDriver returns false, and logs a warning, when it is called on a copy without authority, on an entity with no predicted behavior, or on an entity a client owns: a player's pawn cannot be driven by anyone else. The vehicle has one driver at a time. Nebula announces the change to every client that holds the vehicle and carries it with the vehicle when another worker takes it over.
What each side does:
- The worker runs the driver's input every tick, even if the vehicle has a longer
UpdateInterval, and sends the driver the vehicle's state. It ignores input for the vehicle from anybody else. A driven vehicle cannot sleep. - The driver's client predicts the vehicle before its own pawn on each tick, so a pawn in the driving seat moves with the vehicle's new pose.
NebulaClient.DrivenEntitieslists what the client drives, andNetworkBehaviour.IsDriveris true on that client. - Every copy of the behavior, on the worker and on each client, gets
OnDriverChanged(previous, current), with0for "nobody". Use it for cameras, prompts and HUDs.
Seat the passengers
Put a Container on the vehicle's root so that it carries a box, and keep the driver's and passengers' pawns inside it: your pawn controller holds each pawn at its seat in the box's coordinates. Everything in the box moves with the vehicle, stays in its seat on every client, and changes worker with the vehicle. This also works when the vehicle itself is inside a moving container, such as a car in a ship's hold. See Dynamic containers and Physics frames.
Handle a driver who goes quiet or leaves
If the driver's input stops arriving, the worker repeats the last input for DriverInputTimeoutTicks (30 ticks, half a second), then runs GatherServerInput until the driver's input returns. The driver keeps the seat, so the vehicle does not stay at full throttle when a link drops. Override DriverInputTimeoutTicks to change the wait.
When the driver's session ends, for example because the player left, the worker that holds both the vehicle and the driver's pawn clears the driver. That is the usual case, since the pawn rides in the vehicle. A disconnected player who can still reconnect keeps the seat; to free it at once, call ClearDriver from NebulaGameMode.OnPlayerDisconnected.
Know the limits
- Something only the worker knows about, such as another player's vehicle hitting yours, is corrected in one step by the size of the difference. The driver sees that step eased over about 100 ms (see Smooth corrections); it still starts one round trip late.
- A vehicle that needs PhysX to move cannot be predicted. It still works as a server-owned entity that the worker simulates and every client interpolates, with the delay described above.
Perform a lag-compensated query
Each worker records a bounded window of recent ticks for every entity it holds, ghosts included. Call NetworkIdentity.StateAt(tick) to read the pose, velocity, container and marked variables at a tick, on the authority or on a ghost.
Use NetworkTime.RenderTick as the basis for the tick a client saw, and include that tick in the player's input when the worker must evaluate an action against past entity positions.
Nebula supplies the history but does not implement a complete hit-validation system. See Lag compensation for the window, the staleness bound, [SyncHistory] variables and the reference sample.
Read correction diagnostics
Use these properties on PredictedBehaviourBase:
| Property | Meaning |
|---|---|
Corrections | Number of client corrections since spawn or reset. |
LastCorrectionMagnitude | Position error from the latest correction. |
MaxCorrectionMagnitude | Largest correction since spawn or ResetCorrectionStats. |
CorrectionOffset | The offset between the simulated and drawn position, in meters, in the container's local space. Zero when nothing is being eased. |
CorrectionOffsetMeters and CorrectionOffsetDegrees | The size of the position and rotation offsets. |
DrawnPosition and DrawnRotation | The world pose the entity is drawn at. |
ServerEventsPosted and ServerEventsDropped | On the worker, server events accepted and refused by PostServerEvent. |
ServerEventReplays | On the owning client, replays started because a report carried an event for a tick already simulated. |
Use NebulaClient.RttMs, InputLeadTicks, InputLeadAdjustTicks, LastReportedInputLead, and InputLeadIncreases to inspect the network timing that affects prediction.