Physics, handover state and the sync channel
Replicate rigidbody motion, transfer worker-only state, and create a custom per-tick sync component.
Choose a replication path based on how other processes use the state:
- A root
NetworkTransformsends selected transform fields when they change, with reliable recovery after movement. NetworkVariablesends state that changes occasionally. When one variable changes, Nebula sends all variables for that entity.- The sync channel sends component data by tick. Child
NetworkTransformcomponents andNetworkAnimatoruse this channel. - The handover-state methods send worker-only state once during an authority transfer.
Use this page to configure NetworkRigidbody, transfer custom state between workers, or create a component based on NetworkSyncBehaviour.
NetworkRigidbody
Add NetworkRigidbody to a physics entity controlled by a worker. Add it to the same GameObject as its NetworkIdentity and Unity Rigidbody. It requires NetworkTransform; use that component to select axes and configure interpolation.
| Copy | Body state |
|---|---|
| Authoritative worker | Dynamic. PhysX simulates it. Each NetworkTick mirrors Body.linearVelocity into NetworkIdentity.Motion.Velocity, so the root transform stream carries the velocity remote copies extrapolate with and a handover keeps momentum. |
| Ghost on a neighbouring worker | Kinematic. The worker moves it to the pose the owner streamed, one tick behind, before physics syncs. Other dynamic bodies on that worker collide with it as a moving kinematic. |
| Every client | Kinematic. Follows the root transform stream through RemoteInterpolator. |
When a worker gains authority, NetworkRigidbody restores the body’s kinematic or dynamic state, linear and angular velocity, and sleeping state. When the worker loses authority, the component makes the body kinematic.
A NetworkRigidbody interacts exactly with bodies on its own worker and one-sidedly with ghosts. A joint between two entities in different containers is not supported. See Distributed physics for the model and the checks that report such joints.
Both linear and angular velocity travel in the handover snapshot, even if NetworkTransform.SyncVelocity is off. Angular velocity is not sent to clients or ghosts each tick. The next worker also receives the body’s kinematic and sleeping state.
Call SetVelocity(Vector3 linear, Vector3 angular = default) on the authoritative worker to launch a newly spawned rigidbody. The method updates Identity.Motion.Velocity and the dynamic Rigidbody:
var identity = worker.SpawnPrefab(projectilePrefab, origin, Quaternion.LookRotation(direction), Container);
var body = identity.GetComponent<NetworkRigidbody>();
if (body != null) body.SetVelocity(direction * launchSpeed);Prediction and rigidbodies do not mix
Do not use NetworkRigidbody as the physics body for a client-predicted controller. Dynamic rigidbody state differs between the client and worker and cannot replay deterministically with this component. Use NetworkRigidbody for worker-simulated physics entities.
Handover state
Every NetworkBehaviour has two virtual methods:
public virtual void WriteHandoverState(NetworkWriter writer) { }
public virtual void ReadHandoverState(NetworkReader reader) { }WriteHandoverState is called on the outgoing worker while it builds the AuthorityTransfer, before OnLostAuthority. ReadHandoverState is called on the incoming worker after the entity's pose, velocity, NetworkVariable values and sync-channel keyframes have been applied and before OnGainedAuthority. What you write in one you must read, exactly, in the other.
Write state here only when the next simulating worker needs it and Nebula does not already replicate it. Examples include angular velocity, cooldown timers, random-number-generator state, and the remaining time before a door closes.
Do not include state that a client or ghost must see. Use a NetworkVariable or the sync channel for that state. Do not include values you can calculate from other replicated state.
Nebula writes one length-prefixed chunk for each behavior, in component order: [count:byte]{[len:ushort][chunk]}. Each behavior reads only its own chunk.
If a behavior reads past its chunk, Nebula logs the behavior type and entity name. The next behavior can still read its own bytes. Reading fewer bytes than the chunk contains does not affect the next behavior.
Nebula sends handover state only in an AuthorityTransfer message between workers. It does not send this state every tick, include it in client spawn messages, or cache it at the gateway. A transfer between two containers on the same worker does not serialize it.
The sync channel
Any NetworkBehaviour can replicate through the sync channel by overriding a small set of members:
| Member | Role |
|---|---|
bool HasSyncState | Return true to opt in. NetworkIdentity.Initialize collects the opted-in behaviours into SyncBehaviours. |
Delivery SyncDelivery | Delivery.ReliableOrdered (default) or Delivery.Sequenced (unreliable, newest wins). |
void MarkSyncDirty() | Authority: ask the worker to send this behaviour's chunk at the end of the current tick. |
void WriteSyncState(NetworkWriter writer, bool full) | Authority: write the state. full is a keyframe: write everything. Otherwise only what changed is needed, though writing everything is always correct. |
void ReadSyncState(NetworkReader reader, uint tick, bool full) | Non-authoritative copies: read what WriteSyncState wrote. tick is the simulation tick the state belongs to, 0 for a spawn snapshot. |
void OnSyncStateSent() | Authority: the tick's sends are done; clear per-tick pending deltas. protected internal. |
void RemoteTick(double renderTick) | Every copy this process does not simulate: present the state for renderTick. |
Who sends what, when
After each simulation tick, the worker checks the sync behaviors on its authoritative entities. It writes a chunk when the behavior is due:
- a behaviour is due when it is dirty;
- on a keyframe tick (
tick % NetworkIdentity.SyncKeyframeInterval == 0, every 30 ticks) or on a behaviour's first send since gaining authority, a chunk that is written is a keyframe (full == true); - an unreliable behaviour is also due on every keyframe tick whether or not it is dirty, so a lost delta is healed within half a second at 60 Hz. A reliable behaviour never loses a delta and only sends when dirty.
Nebula groups chunks by delivery class. It sends an EntityState message to the gateway and a GhostSyncState message to each worker with a ghost copy. Each message identifies the entity, epoch, tick, and container.
After sending the tick, Nebula calls OnSyncStateSent on each dirty behavior and clears its dirty flag.
Gaining authority (spawn or handover in) resets every sync behaviour to "never sent" and marks it dirty, so a new epoch always opens with keyframes. That is what the gateway's cache and a fresh ghost start from.
Envelope format
SyncStateCodec writes the same envelope for the per-tick messages and for the spawn snapshot:
[count:byte] { [behaviourIndex:byte] [flags:byte] [len:ushort] [chunk:len bytes] } * countflags is SyncStateCodec.ChunkFlags: Full (1) for a keyframe, None for a delta. Bits 1 and 2 carry the behavior's sync audience, which the gateway uses to decide which clients receive the chunk. Each chunk is bounded exactly like handover state: ReadSyncState gets a reader limited to its own bytes, an over-read throws and is logged, an under-read is harmless. Chunks for a behaviour index the receiver does not have are skipped.
The receiving side
NetworkIdentity.ReadSyncState(reader, tick, container) records the container used to encode the values, then passes each chunk to its behavior. Client state messages use their simulation tick. Spawn snapshots, ghost spawns, and authority transfers use tick 0.
Nebula separates receiving state from presenting it. It calls RemoteTick(renderTick) on every non-authoritative copy.
On clients, renderTick is delayed by NebulaConfig.InterpolationDelayTicks so a component can interpolate between received samples. On workers, Nebula updates each ghost before physics synchronization and raycasts. Apply state directly in ReadSyncState when the component does not need smoothing.
What the gateway caches
The gateway caches the newest complete keyframe for each sync behavior. When a client joins, the gateway includes those keyframes in the entity's spawn message, except those of behaviors whose sync audience does not include that client. The new client therefore starts from recent state instead of the prefab defaults.
The gateway does not cache partial component updates. Unreliable behaviors send periodic keyframes, so a new client can wait up to SyncKeyframeInterval ticks for current state. Reliable child NetworkTransform updates always carry a complete selected snapshot. Custom reliable behaviors must supply complete snapshots when needed; a behavior that sends a partial update and then stops has no periodic keyframe guarantee.
This is why a keyframe must be self-contained: write everything a fresh copy needs, not just what changed since the last keyframe.
A complete example
A door on a child transform of a larger entity. The hinge angle changes every tick while the door moves, so it goes on the sync channel unreliably with buffered interpolation; a locked flag changes rarely and rides the same chunk. The auto-close deadline is a tick number only the simulating worker cares about, so it travels as handover state.
This custom behavior drives the hinge itself, so do not add a NetworkTransform to the same hinge. The entity root needs one only if the root moves. For a simpler door, replicate an open/closed NetworkVariable and animate the leaves locally on every copy.
using Nebula;
using UnityEngine;
public sealed class NetworkDoor : NetworkBehaviour
{
[SerializeField] private Transform _hinge;
[SerializeField] private float _openAngle = 90f;
[SerializeField] private float _degreesPerSecond = 120f;
[SerializeField] private float _autoCloseSeconds = 3f;
// Authority-side simulation state.
private float _angle;
private bool _locked;
private bool _open;
private uint _closeAtTick; // handover state: the next worker must keep the timer
private float _lastSentAngle;
private bool _lastSentLocked;
private bool _angleDirty, _lockedDirty;
// Receiver-side ring of samples keyed by tick, like NetworkTransform's.
private struct Sample { public uint Tick; public float Angle; public bool Valid; }
private const int Capacity = 64;
private readonly Sample[] _ring = new Sample[Capacity];
private uint _latestTick;
private bool _anySample;
public override bool HasSyncState => true;
public override Delivery SyncDelivery => Delivery.Sequenced;
// ---- authority ------------------------------------------------------------------------------
/// <summary>Worker only. Open the door unless it is locked; it closes itself a few seconds later.</summary>
public void Open()
{
if (!HasAuthority || _locked) return;
_open = true;
_closeAtTick = NetworkTime.Tick + (uint)(_autoCloseSeconds * NetworkTime.TickRate);
}
public void SetLocked(bool locked)
{
if (!HasAuthority) return;
_locked = locked;
}
public override void NetworkTick(uint tick, float deltaTime)
{
if (_open && tick >= _closeAtTick) _open = false;
float target = _open ? _openAngle : 0f;
_angle = Mathf.MoveTowards(_angle, target, _degreesPerSecond * deltaTime);
_hinge.localRotation = Quaternion.Euler(0f, _angle, 0f);
if (Mathf.Abs(_angle - _lastSentAngle) >= 0.01f) _angleDirty = true;
if (_locked != _lastSentLocked) _lockedDirty = true;
if (_angleDirty || _lockedDirty) MarkSyncDirty();
}
public override void WriteSyncState(NetworkWriter writer, bool full)
{
bool angle = full || _angleDirty;
bool locked = full || _lockedDirty;
writer.WriteByte((byte)((angle ? 1 : 0) | (locked ? 2 : 0)));
if (angle) { writer.WriteHalf(_angle); _lastSentAngle = _angle; }
if (locked) { writer.WriteBool(_locked); _lastSentLocked = _locked; }
}
protected override void OnSyncStateSent()
{
_angleDirty = false;
_lockedDirty = false;
}
public override void OnGainedAuthority()
{
// A new epoch opens with a keyframe; make sure it reflects the hinge we were handed.
_angle = _hinge.localEulerAngles.y;
_open = _closeAtTick > NetworkTime.Tick;
for (int i = 0; i < Capacity; i++) _ring[i].Valid = false;
_anySample = false;
}
// ---- handover: only the next simulating worker needs the timer ------------------------------
public override void WriteHandoverState(NetworkWriter writer)
{
writer.WriteUInt(_closeAtTick);
}
public override void ReadHandoverState(NetworkReader reader)
{
_closeAtTick = reader.ReadUInt();
}
// ---- non-authoritative copies ---------------------------------------------------------------
public override void ReadSyncState(NetworkReader reader, uint tick, bool full)
{
byte fields = reader.ReadByte();
if ((fields & 2) != 0) _locked = reader.ReadBool();
if ((fields & 1) == 0) return;
float angle = reader.ReadHalf();
if (tick == 0)
{
// Spawn snapshot (late joiner, fresh ghost): apply at once, nothing to interpolate against.
_hinge.localRotation = Quaternion.Euler(0f, angle, 0f);
_anySample = false;
return;
}
if (_anySample && tick + Capacity <= _latestTick) return; // too old
if (!_anySample || tick > _latestTick) _latestTick = tick;
_anySample = true;
_ring[tick % Capacity] = new Sample { Tick = tick, Angle = angle, Valid = true };
}
public override void RemoteTick(double renderTick)
{
if (!_anySample) return;
if (renderTick >= _latestTick)
{
_hinge.localRotation = Quaternion.Euler(0f, _ring[_latestTick % Capacity].Angle, 0f);
return;
}
uint floor = (uint)System.Math.Floor(renderTick);
Sample before = default, after = default;
for (int i = 0; i < Capacity; i++)
{
uint t = floor - (uint)i;
if (t > floor) break; // underflow
var s = _ring[t % Capacity];
if (s.Valid && s.Tick == t) { before = s; break; }
}
for (uint t = floor + 1; t <= _latestTick; t++)
{
var s = _ring[t % Capacity];
if (s.Valid && s.Tick == t) { after = s; break; }
}
float angle;
if (before.Valid && after.Valid)
{
float f = (float)((renderTick - before.Tick) / (after.Tick - before.Tick));
angle = Mathf.LerpAngle(before.Angle, after.Angle, f);
}
else angle = after.Valid ? after.Angle : before.Angle;
_hinge.localRotation = Quaternion.Euler(0f, angle, 0f);
}
}Points worth noting in the example:
NetworkTickonly runs on the authoritative worker, so change detection andMarkSyncDirtyneed no guards.- The delta writes a field mask, and the receiver's
_lockedkeeps its previous value when the mask leaves it out. The keyframe path writes both fields, so a late joiner is complete. _lastSentAngleis updated when the value actually goes on the wire, inWriteSyncState, not inNetworkTick; the threshold compares against what remote copies have.OnSyncStateSentclears the pending flags once the worker has sent the tick to every peer and gateway. Clearing them inWriteSyncStatewould be wrong: the worker may call it more than once per tick (one envelope per destination).- The handover carries only
_closeAtTick._lockedis already on the sync channel: theAuthorityTransfercarries the same spawn data as aGhostSpawn, including a sync keyframe from every behaviour, and the incoming worker applies that (with tick 0) beforeReadHandoverStateruns, so even a worker that held no ghost of the entity has the current_lockedby then. The angle needs no handover entry either: the hinge's rotation is on the object, andOnGainedAuthorityreads it back. - Ghosts on a worker also run
RemoteTick, so a door's collider on a neighbouring worker is at the same angle the owner's stream says. If you only need visuals you can early-out onIsServer.
If the owning client must drive your component, derive from NetworkSyncBehaviour. It adds the authority setting, owner-to-worker relay, and AuthorityTick callback. Use IsSyncAuthority to check whether the current process should produce state. See NetworkTransform for a complete implementation.
Related
NetworkTransform
Opt into root or child transform replication.
NetworkAnimator
The built-in sync-channel component for animators.
Private sync state
Send a behavior's sync state only to its owner, to no client, or to clients you choose.
Containers and handover
When and how an authority transfer happens.
Distributed physics
What cross-worker physics does and does not do.
Serialization
NetworkWriter and NetworkReader.