Nebula

RPCs and worker messages

Run methods on clients or authoritative workers, and exchange game-defined messages between workers.

Use a remote procedure call (RPC) to run a NetworkBehaviour method on another Nebula process. Mark the receiving method with an RPC attribute, then pass that method and its arguments to the matching sender method.

Nebula finds RPC methods through reflection when it initializes the behavior.

Choose an RPC

SenderAttributeCall fromRuns on
ClientRpc[ClientRpc]The worker with authorityEvery connected client that knows the entity
OwnerRpc[ClientRpc]The worker with authorityOnly the client that owns the entity
ServerRpc[ServerRpc]The client that owns the entityThe worker with authority
AuthorityRpc[AuthorityRpc]Any worker with an authoritative or ghost copyThe worker with authority

OwnerRpc uses [ClientRpc]; it is a targeted form of the same message.

Declare and send a call

Do not call an RPC method directly when you want to send a network message. Pass its method group to the matching sender:

PlayerController.cs
public void RequestUse()
{
    if (!IsOwner) return;
    ServerRpc(RpcRequestUse);
}

[ServerRpc]
private void RpcRequestUse()
{
    if (!HasAuthority) return;
    ClientRpc(RpcPlayedUseEffect, transform.position);
}

[ClientRpc]
private void RpcPlayedUseEffect(Vector3 position)
{
    PlayUseEffect(position);
}

The sender's generic arguments let the compiler check argument types against the method signature. Current overloads support these argument counts:

SenderArguments
ClientRpc0 to 4
OwnerRpc0 to 3
ServerRpc0 to 3
AuthorityRpc0 to 4

If you need more values, put them in one struct that implements INetworkSerializable. See Serialization.

Use a sender that matches the method attribute. For example, passing an [AuthorityRpc] method to ClientRpc throws InvalidOperationException at the call site.

Follow ownership rules

Nebula checks the caller before it sends an RPC:

  • Send ClientRpc and OwnerRpc only from the worker that has authority.
  • Send ServerRpc only from the client that owns the entity.
  • Send AuthorityRpc only from a worker with an authoritative or ghost copy.
  • Send an RPC only while the entity is spawned.

Nebula logs a warning and discards a call that violates these rules.

The gateway adds the sending client ID to a ServerRpc and verifies that the client owns the entity. The receiving worker verifies ownership again. Because the handler has no sender argument, use OwnerClientId when it needs the caller's client ID.

Send an event to clients

Use ClientRpc for an event that only connected clients need, such as a visual or audio effect.

private void PublishDoorSound(Vector3 position)
{
    if (!HasAuthority) return;
    ClientRpc(RpcPlayDoorSound, position);
}

[ClientRpc]
private void RpcPlayDoorSound(Vector3 position)
{
    AudioSource.PlayClipAtPoint(DoorSound, position);
}

An effect that is only visible near the entity does not need to reach every client. Give the attribute a Radius (metres) and the gateway delivers the broadcast only to clients whose pawn is within that distance of the entity; a targeted OwnerRpc bypasses the distance filter but still requires visibility:

[ClientRpc(Radius = 150f)]
private void RpcShotFired(Vector3 origin, Vector3 end, bool hit)
{
    DrawTracer(origin, end, hit);
}

With hundreds of NPCs shooting, this keeps each client's reliable stream to the fights it can see.

Send an event that must be fast, not guaranteed

By default a client RPC is reliable and ordered: it arrives exactly once, but a lost packet holds it, and everything reliable behind it, until Nebula retransmits. For a message that is only useful if it arrives quickly, such as a provisional hit marker, set Delivery on the attribute:

[ClientRpc(Delivery = Delivery.Sequenced)]
private void RpcHitMarker(int targetId)
{
    ShowHitMarker(targetId);
}

Delivery applies to ClientRpc and OwnerRpc alike, because both use [ClientRpc]. The values are:

ValueBehavior
Delivery.ReliableOrdered (default)Arrives exactly once, in order with other reliable messages.
Delivery.SequencedIs sent once and never retransmitted. A lost packet is lost, and a packet that a newer sequenced packet overtakes is dropped.

The worker sends the call on the sequenced channel, and the gateway forwards it to the client on the same channel instead of adding it to the client's reliable batch. Keep these limits in mind:

  • A sequenced call can fail to arrive. Send something reliable, such as the confirmed result, for anything the client must not miss.
  • A sequenced call is not ordered against reliable RPCs, spawns, variables, or state. It can arrive before or after a reliable message that was sent earlier or later. In particular, it can reach a client before the spawn of the entity it targets, in which case the client ignores it.
  • A call whose serialized message exceeds about 400 bytes is sent reliable, because a sequenced packet cannot be split. Nebula logs a warning when that happens.
  • A gateway drops a sequenced call for an entity it has not finished describing to the client, instead of holding it.

Nebula has no unordered, unreliable class beyond Sequenced. This setting changes no message bytes, so clients and gateways of the same wire protocol interoperate.

Use OwnerRpc when only the owning client needs the result:

private void ConfirmPurchase(int itemId)
{
    if (!HasAuthority || Identity.OwnerClientId == 0) return;
    OwnerRpc(RpcPurchaseConfirmed, itemId);
}

[ClientRpc]
private void RpcPurchaseConfirmed(int itemId)
{
    ShowPurchaseConfirmation(itemId);
}

Do not call OwnerRpc for an entity with OwnerClientId == 0. Client ID 0 means broadcast at the gateway, so an owner call on a server-driven entity reaches every client allowed to observe it.

Clients reject an RPC with an older authority epoch than the entity state they already hold.

Send a request from the owning client

Use ServerRpc when the client that owns an entity asks its worker to perform an action.

public void RequestRename(string requestedName)
{
    if (!IsOwner) return;
    ServerRpc(RpcRequestRename, requestedName);
}

[ServerRpc]
private void RpcRequestRename(string requestedName)
{
    if (!HasAuthority) return;
    DisplayName.Value = ValidateName(requestedName);
}

The gateway routes the request to the worker that it believes has authority. If that worker has already transferred the entity, it forwards the request to the new worker.

Treat values from a ServerRpc as untrusted client input. Check ranges, permissions, cooldowns, and relevant game state on the worker before you apply them.

Call the worker that controls another entity

Use AuthorityRpc when one worker must change an entity that another worker may control. The local worker must have an authoritative or ghost copy of the target.

internal void ClaimDamage(float amount, ulong attackerNetworkId)
{
    AuthorityRpc(RpcApplyDamage, amount, attackerNetworkId);
}

[AuthorityRpc]
private void RpcApplyDamage(float amount, ulong attackerNetworkId)
{
    if (!HasAuthority) return;
    Health.Value = Mathf.Max(0f, Health.Value - ValidateDamage(amount));
}

When the calling worker controls the target, Nebula serializes the arguments and calls the method locally. When the local copy is a ghost, Nebula sends the call over the worker-to-worker link to the worker it believes has authority.

Include every value the receiver needs in the arguments. It may not have a copy of the calling entity.

AuthorityRpc delivers a claim; it does not decide whether the claim is valid. Validate game-specific results on the receiving worker when necessary.

What Nebula promises for a cross-worker call

A call that leaves its worker follows these rules. The design record is docs/cross-worker-calls.md in the repository.

  • Delivery. The call travels reliably and in order on one worker link, the same channel handover uses. Nebula does not order calls that arrive over different links: two workers calling the same entity, or a forwarded call and a direct one, can run in either order. Put a tick or your own sequence number in the arguments when order matters.
  • Identity. Every call carries an ID that the sender mints. A worker keeps the IDs it has applied for 4096 calls or 600 ticks (10 seconds), whichever runs out first, and rejects a repeat as a duplicate. Within those bounds a call runs at most once.
  • Handover in flight. If the target changes worker after the call is sent, the worker that receives it forwards it to the new owner. Forwarding stops after AuthorityCallMaxHops forwards (default 3, set in NebulaConfig or with -nebula-authority-call-hops), and the call is rejected.
  • Epoch fencing. The call carries the epoch of the caller's copy. The receiving worker rejects a call whose epoch is ahead of its own copy or more than AuthorityCallMaxHops handovers behind it. A call that reached its target through legitimate forwarding is never further behind than that.
  • Outcome. A rejected call is logged with its reason on the worker that rejected it. To learn the outcome on the calling worker, use AuthorityRpcWithReply.

Nebula does not retry a call, does not guarantee delivery through a worker or link failure, and does not run several calls atomically. Accepted means the method ran; whether the handler accepted the claim is between your handler and your game.

Learn what happened to a call

Use AuthorityRpcWithReply when the caller must know whether the call was applied. It takes the same method and arguments as AuthorityRpc, plus a callback and an optional timeout in seconds (default 5). The callback runs exactly once on the calling worker's main thread.

internal void ClaimPickup(ulong playerNetId)
{
    AuthorityRpcWithReply(RpcClaimPickup, playerNetId, result =>
    {
        if (result.Succeeded) return;
        // The claim did not run: the target moved too many times, was despawned,
        // this copy was stale, or nobody answered in time.
        NebulaLog.Info($"pickup claim failed: {result.Outcome}");
    });
}

[AuthorityRpc]
private void RpcClaimPickup(ulong playerNetId)
{
    if (!HasAuthority || Claimed.Value) return;
    Claimed.Value = true;
}

AuthorityCallResult.Outcome is one of:

OutcomeMeaning
AcceptedThe worker with authority ran the method once.
RejectedStaleEpochThe caller's copy was too far behind, or ahead of, the entity. TargetEpoch holds the entity's epoch.
RejectedUnknownEntityNo worker on the path holds the entity.
RejectedHopLimitThe target changed worker more than AuthorityCallMaxHops times while the call was chasing it.
RejectedDuplicateA call with the same ID was already applied.
RejectedUnreachableThe caller, or a worker forwarding the call, has no connection to the entity's owner.
TimedOutNo reply arrived before the timeout. The call may or may not have run.

The result also carries the call ID, the deciding worker's epoch for the entity, and how many times the call was forwarded. A call the local worker applies itself reports Accepted with call ID 0 and never crosses the network.

The fire-and-forget AuthorityRpc overloads follow the same rules; they only skip the reply.

Send a worker message

Use a worker message when you need to address a worker rather than an entity. A message has a game-defined integer kind and a payload written with NetworkWriter.

Register one handler for each kind:

private const ushort AreaQuery = 100;
private const ushort AreaResult = 101;

private void Register(NebulaWorker worker)
{
    worker.RegisterMessageHandler(AreaQuery, OnAreaQuery);
}

private void OnAreaQuery(
    string fromWorkerId,
    ushort fromWorkerIndex,
    NetworkReader reader)
{
    var center = reader.ReadVector3();
    var radius = reader.ReadFloat();
    // Inspect authoritative entities, then send a reply if needed.
}

Send by worker ID or index:

bool sent = worker.SendToWorker(targetWorkerIndex, AreaQuery, writer =>
{
    writer.WriteVector3(center);
    writer.WriteFloat(radius);
});

SendToWorker returns false when the target is not connected. It calls the handler directly when the target is the current worker. Messages use ReliableOrdered delivery unless you pass Delivery.Sequenced.

A worker message is fire-and-forget. Nebula sends it once, on the channel you chose, to the worker you named. It carries no call ID, is not checked against an epoch, is not forwarded when an entity moves, and produces no reply. A message on a live reliable link arrives exactly once and in order with other reliable messages on that link; if the link drops, the message is lost and the sender is not told. Include an authority check in a handler that acts on an entity, and design for the entity to move before the message arrives. When a message must follow an entity, use AuthorityRpc; when you need an answer, use WorkerQuery below.

Use ContainerRegistry.Along(a, b, margin, result) to find the containers crossed by a line segment. You can then send a query to the workers that own those containers. Use ConnectedWorkerIndices to inspect connected peers.

Collect replies with WorkerQuery

Use WorkerQuery when you expect one response from each target worker. It manages request IDs, pending workers, timeouts, and completion.

private WorkerQuery _query;

private void RegisterQuery(NebulaWorker worker)
{
    _query = new WorkerQuery(worker, AreaQuery, AreaResult, OnAreaQuery);
}

private void OnAreaQuery(
    string fromWorkerId,
    ushort fromWorkerIndex,
    NetworkReader request,
    NetworkWriter reply)
{
    var center = request.ReadVector3();
    var radius = request.ReadFloat();
    reply.WriteInt(CountAuthoritativeEntities(center, radius));
}

private void CountAcrossWorkers(IReadOnlyList<ushort> targets)
{
    int total = 0;
    _query.Send(
        targets,
        writer =>
        {
            writer.WriteVector3(transform.position);
            writer.WriteFloat(10f);
        },
        onReply: (_, _, reader) => total += reader.ReadInt(),
        onComplete: allAnswered => ShowCount(total, allAnswered),
        timeoutSeconds: 0.3f);
}

private void Update()
{
    _query?.Update();
}

Call Update regularly so the query can expire requests. onComplete receives false when the timeout passes before every connected target replies. A late reply is counted and discarded.

Dispose the query when its owner is destroyed. This unregisters its request and response message kinds.

Follow RPC constraints

  • Use only types supported by NetworkSerialization for parameters.
  • Do not overload RPC method names in one inheritance chain. Nebula identifies a method by a 32-bit hash of its name.
  • Declare handlers with any C# visibility, including private.
  • Return void; RPC return values are not supported.
  • Expect handler exceptions to be logged on the receiving process, not thrown on the sender.
  • Expect reliable ordered delivery unless you set Delivery on a [ClientRpc]. Do not use an RPC as persistent state or predicted tick input.

Choose an RPC or replicated state

Use a NetworkVariable for state that a late client or new ghost must receive, such as health, a display name, or whether a door is open.

Use ClientRpc or OwnerRpc for a one-time client event that does not need to be reconstructed later.

Use ServerRpc for an owner request that the authoritative worker must validate.

Use AuthorityRpc for an interaction with an entity that the local worker can see but may not control.

Use a worker message or WorkerQuery when the target is a worker or when the local worker has no copy of the relevant entity.

On this page