Nebula

Lag compensation

Ask a worker what an entity looked like at a recent tick, on the authority or on a ghost, and validate a client's claim against it.

A worker holds the present. A shot fired on a client left a gun aimed at what that client's screen showed, which is interpolation delay plus transit plus input lead behind the worker's current tick. To judge that shot the worker needs the past.

Every worker records a bounded window of recent ticks for each entity it holds — the entities it simulates and the ghosts of its neighbours' entities. NetworkIdentity.StateAt(tick) reads that window.

if (victim.TryGetStateAt(aimTick, out var state))
{
    // state.Position and state.Rotation are the world pose at aimTick.
    // state.Available is already true here; state.Tick says which tick it really is.
}

Nebula supplies the history. Rewinding colliders, performing the hit test and deciding which tick a client may claim stay yours; see What Nebula does not do.

What an entry holds

StateAt returns a HistoricalState by value:

MemberMeaning
AvailableAn entry was found. Check this first; every other member is meaningless when it is false.
TickThe server tick the entry belongs to. The tick you asked for, unless it fell in a gap.
Position, RotationThe world pose at that tick.
VelocityThe world-space velocity the authority reported for that tick.
ContainerThe container the entity was in. Its InstanceId identifies the simulation scope; adjacent containers in the same scope can interact.
EpochThe entity's authority epoch at that tick.
FromAuthoritytrue when this worker recorded the entry as the authority, false when it recorded it from a neighbour's stream.
TryGetValue(variable, out value)The value a [SyncHistory] variable held at that tick.

OldestAvailableTick and NewestAvailableTick on the identity say what the window can answer. NebulaWorker.TryGetStateAt(netId, tick, out state) asks the same question by net ID for anything the worker holds.

The window

NebulaConfig.StateHistorySeconds sets how long each entity's history is. The default is about half a second (32 ticks at 60 Hz, 16 at 30), which covers a player's interpolation delay plus half a round trip plus input lead at a few hundred milliseconds of latency. -nebula-state-history <ticks> overrides it per process, and 0 turns recording off entirely.

An entry costs about 80 bytes plus whatever your [SyncHistory] variables serialize to (at least 32 bytes per entry once anything is marked). With the default window and no marked variables that is roughly 2.5 KB per entity per worker that holds a copy — an entity ghosted on three neighbours pays it four times, so 2 000 copies on a worker cost about 5 MB. Recording is allocation-free once an entity's ring is warm.

Widen the window if your players are further away than the default covers; a claim outside it cannot be checked.

What the answer promises

Inside the window, the entry for that tick. A worker sends a ghost's state only when it changed, so an entity standing still leaves gaps. Within four ticks either side the nearest recorded entry is returned instead, and state.Tick tells you which tick it actually is. Nothing is interpolated between entries.

Outside the window, Available is false. Nebula never extrapolates and never quietly returns the nearest edge: a tick older than OldestAvailableTick or newer than NewestAvailableTick is simply unanswerable, and your validator decides what to do about it. (TryGetPoseAt keeps its older behaviour: false plus the entity's current pose, for callers that only want a best effort.)

A ghost is exactly one tick behind the owner. A worker running its tick n holds, for every ghost, the owner's tick n − 1 at the newest: the owner built that stream during its previous tick. Entries are tagged with the owner's tick, never the receiving worker's, so StateAt(412) means the same instant on every worker in the mesh. Poses older than the newest are not approximations — they are the owner's own values for those ticks, to within the wire precision of the state stream.

Under packet loss on the unreliable stream the bound degrades to "the newest tick that arrived". That is what NewestAvailableTick reports, which is why a validator should look at it rather than assume.

History survives a handover. A worker that ghosted an entity and then gained authority over it can still answer for the ticks it recorded as a ghost. A cross-worker call that chases an entity across a handover therefore lands somewhere that can still see the tick the caller is talking about.

Record a variable in the history

Pose, velocity, container and epoch are always recorded. A NetworkVariable is not, because a snapshot costs a serialization every tick on every worker that holds a copy. Mark the ones a validator needs:

Pawn.cs
public sealed class Pawn : NetworkBehaviour
{
    // A crouching player is a shorter capsule: a validator wants the stance at the claimed tick.
    [SyncHistory] public NetworkVariable<byte> Stance = new NetworkVariable<byte>();

    // Not marked: nothing validates a claim against a score.
    public NetworkVariable<int> Score = new NetworkVariable<int>();
}

Read it back from the entry:

if (victim.TryGetStateAt(aimTick, out var state) &&
    state.TryGetValue(victim.GetComponent<Pawn>().Stance, out byte stance))
{
    float height = stance == Crouched ? 1.1f : 1.8f;
}

On a ghost, a marked variable holds the value the owner's stream had delivered by that tick. The worker sends an entity's variable update ahead of its state entry for the same tick so the two agree, but the unreliable state batch travels on another channel and can cross it, so the promise is the same one tick as the pose — not tighter.

Validate a claim

The shape of a validator, and what belongs to you at each step:

public void Fire(Vector3 origin, Vector3 direction, uint aimTick) =>
    ServerRpc(RpcFire, origin, direction, aimTick);

[ServerRpc]
private void RpcFire(Vector3 origin, Vector3 direction, uint aimTick)
{
    // 1. Is the claim one you are willing to check? Nebula does not decide this.
    uint now = worker.CurrentTick;
    if (aimTick > now || now - aimTick > MaxClaimTicks) return;

    // 2. Cast the ray at that tick. Nebula poses what the worker holds at its recorded pose for the length of the
    //    cast, puts it back, and carries the cast on through any open portal into the room behind a door.
    _options.Tick = aimTick;
    _options.RewindNear = true;
    if (!worker.PortalQueries.Raycast(PortalAsker.Of(Identity), Identity.InstanceId, origin, direction, Range, out var hit, _options)) return;
    if (hit.IsAperture || hit.IsTruncated || hit.Entity == null) return; // a closed door, or the edge of what this worker knows

    // 3. Apply the result where the victim lives.
    hit.Entity.GetComponent<SampleHealth>().ApplyDamage(10);
}

The cast is a portal query: it starts in the shooter's scope, so a victim in a separate instance is not a candidate, and it goes on through a door that is open for the shooter into the scope behind it. An entity that crossed a portal after the claimed tick is found in the scope it was in then. If you would rather test your own hitboxes, HistoryRewind is the same rewind on its own, and entity.TryGetStateAt(aimTick, out var state) is what both are built on.

Send the tick with the input rather than inferring it: on the client, NetworkTime.RenderTick is the tick it was rendering. SampleHealth.ApplyDamage wraps AuthorityRpc(RpcApplyDamage, amount) and marks the receiving method with [AuthorityRpc]. Attributes identify receiving methods; the send helpers dispatch calls. This sends damage to a ghost's authoritative worker under the cross-worker call contract, including its rejection and delivery limits.

A complete version ships as the Lag-compensated hitscan sample in the package (Samples~/LagCompensatedHitscan): import it from the Package Manager and read it.

What Nebula does not do

  • StateAt does not move colliders. HistoryRewind and a portal query with a tick do, for an entity's own colliders and for the scope behind a portal, and put everything back; hitboxes that are not the entity's root (bones, a head sphere) are still yours to pose from state.Position, state.Rotation and the marked variables.
  • It does not perform the hit test and does not decide whether a claimed tick is one the client could plausibly have been rendering. Those are game rules.
  • It does not keep history on clients. A client already buffers what it receives for interpolation; history is a worker-side facility.
  • It does not answer for an entity a worker does not hold. Ask the worker that does, with an AuthorityRpc or a WorkerQuery.
  • It does not persist. A restarted worker starts with an empty window.

On this page