Skip to content

NetInvokable RPC System

Unturned's RPC system is NetInvokable — a code-generated remote procedure call system that replaces the legacy Block-based RPC approach. Every networked method is marked with [SteamCall] and paired with a generated read/write implementation in the NetGen assembly. The NetReflection class scans the Assembly-CSharp assembly on startup, builds method info tables, and creates delegates for serialization and deserialization. Invocations flow through ClientStaticMethod/ClientInstanceMethod for server-to-client calls and ServerStaticMethod/ServerInstanceMethod for client-to-server calls.

This article covers the code generation pipeline, the invoke table structure, client/server routing with method indices, reliable vs unreliable delivery options, per-method rate limiting, the NetId-based instance routing system, and the loopback optimization for singleplayer.

Source code location: NetInvokable/*.cs, NetGen/NetInvokable/*.cs, NetGen/NetEnum/*.cs

Code Generation Pipeline

The NetInvokable system relies on code generation executed via Unity Editor's Window > Unturned > Net Gen menu. The generator scans all types in Assembly-CSharp for methods with [SteamCall] attributes. For each such method, it emits a generated class (annotated with [NetInvokableGeneratedClass]) containing static read and write methods annotated with [NetInvokableGeneratedMethod] and ENetInvokableGeneratedMethodPurpose.Read / ENetInvokableGeneratedMethodPurpose.Write.

Generated Method Attributes

csharp
[AttributeUsage(AttributeTargets.Class)]
public class NetInvokableGeneratedClassAttribute : Attribute
{
    public Type targetType;
}

[AttributeUsage(AttributeTargets.Method)]
public class NetInvokableGeneratedMethodAttribute : Attribute
{
    public string targetMethodName;
    public ENetInvokableGeneratedMethodPurpose purpose;
}

The generated class name matches the target type name plus a suffix (e.g., AnimalManager_NetMethods). Each generated read method deserializes parameters from a NetPakReader and calls the source method. Each generated write method serializes parameters into a NetPakWriter.

For example, if LevelManager has:

csharp
[SteamCall(ESteamCallValidation.ONLY_FROM_SERVER)]
public static void ReceiveLevelNumber(byte newLevelNumber) { ... }

The generator emits something like:

csharp
[NetInvokableGeneratedClass(typeof(LevelManager))]
internal static class LevelManager_NetMethods
{
    [NetInvokableGeneratedMethod("ReceiveLevelNumber", ENetInvokableGeneratedMethodPurpose.Read)]
    public static void ReceiveLevelNumber_Read(in ClientInvocationContext context)
    {
        byte newLevelNumber;
        context.reader.ReadUInt8(out newLevelNumber);
        LevelManager.ReceiveLevelNumber(newLevelNumber);
    }

    [NetInvokableGeneratedMethod("ReceiveLevelNumber", ENetInvokableGeneratedMethodPurpose.Write)]
    public static void ReceiveLevelNumber_Write(NetPakWriter writer, byte newLevelNumber)
    {
        writer.WriteUInt8(newLevelNumber);
    }
}

Registration Flow

NetReflection's static constructor calls RegisterFromAssembly(Assembly.GetExecutingAssembly()). The flow:

  1. Enumerate all types in the assembly.
  2. For each type with [NetInvokableGeneratedClass], collect its methods with [NetInvokableGeneratedMethod] into read and write lists.
  3. Reflect the target type's methods for [SteamCall] attributes.
  4. For each [SteamCall] method:
    • If ONLY_FROM_SERVER: create a ClientMethodInfo.
    • If SERVERSIDE or ONLY_FROM_OWNER: create a ServerMethodInfo.
  5. Match each source method to its generated read/write by method name.
  6. Create ClientMethodReceive / ServerMethodReceive delegates from the generated read methods.
  7. Store write MethodInfo for later delegate creation (lazily via CreateClientWriteDelegate() / CreateServerWriteDelegate()).
csharp
private static ClientMethodReceive FindClientReceiveMethod(Type generatedType,
    List<GeneratedMethod> generatedMethods, string methodName)
{
    GeneratedMethod generatedMethod;
    if (FindAndRemoveGeneratedMethod(generatedMethods, methodName, out generatedMethod))
    {
        try
        {
            return (ClientMethodReceive)generatedMethod.info.CreateDelegate(typeof(ClientMethodReceive));
        }
        catch
        {
            Log($"Exception creating delegate for client {generatedType.Name}.{methodName}");
            return null;
        }
    }
    Log($"Unable to find client {generatedType.Name}.{methodName} receive implementation");
    return null;
}

Unclaimed generated methods (methods in the generated type that don't match any [SteamCall] in the target) are logged as warnings — typically indicates a stale generated file needing regeneration.

Method Info Tables

NetReflection maintains two flat lists:

csharp
internal static List<ClientMethodInfo> clientMethods;
internal static uint clientMethodsLength;
internal static int clientMethodsBitCount;
internal static List<ServerMethodInfo> serverMethods;
internal static uint serverMethodsLength;
internal static int serverMethodsBitCount;

ClientMethodInfo

csharp
public class ClientMethodInfo
{
    internal Type declaringType;
    internal string name;
    internal string debugName;
    internal SteamCall customAttribute;
    internal ClientMethodReceive readMethod;
    internal MethodInfo writeMethodInfo;
    internal uint methodIndex;
    // Debug counters for development builds
    internal int handleCount;
    internal CustomSampler readSampler;
    internal CustomSampler deferredReadSampler;
}

ServerMethodInfo

csharp
public class ServerMethodInfo
{
    internal Type declaringType;
    internal string name;
    internal string debugName;
    internal SteamCall customAttribute;
    internal ServerMethodReceive readMethod;
    internal MethodInfo writeMethodInfo;
    internal uint methodIndex;
    internal int rateLimitIndex; // Index into per-connection rate limiting array
    internal int handleCount;
    internal CustomSampler readSampler;
}

The method count is packed into a bit count: clientMethodsBitCount = NetPakConst.CountBits(clientMethodsLength). This determines how many bits encode the method index in the packet header. If there are 150 client methods, the bit count is 8 (covering 0-255).

SteamCall Attribute

csharp
[AttributeUsage(AttributeTargets.Method)]
public class SteamCall : Attribute
{
    public ESteamCallValidation validation;
    public ushort ratelimitHz;
    public int rateLimitIndex;       // set at registration time
    public float ratelimitSeconds;   // computed as 1.0/ratelimitHz
    public string legacyName;       // for backward compat with old tell/ask convention
}

ESteamCallValidation values:

  • ONLY_FROM_SERVER — server writes and sends; client reads and executes.
  • SERVERSIDE — any client can invoke; server validates and executes.
  • ONLY_FROM_OWNER — restricted to the owning client. Server checks sender's ITransportConnection matches the expected owner.

Invoke Table and Message Routing

RPC method calls are delivered through the NetMessaging system as EClientMessage.InvokeMethod (server→client) or EServerMessage.InvokeMethod (client→server).

Client-Side Receive (Server → Client)

ClientMessageHandler_InvokeMethod.ReadMessage():

  1. Reads the method index (ReadUIntBits(clientMethodsBitCount)).
  2. Looks up clientMethods[methodIndex].
  3. Creates a ClientInvocationContext from the reader.
  4. Calls clientMethod.readMethod(context).

The ClientInvocationContext wraps the NetPakReader and provides the SteamPlayer sender info (available for server-to-client callbacks that need it).

Server-Side Receive (Client → Server)

ServerMessageHandler_InvokeMethod.ReadMessage():

  1. Reads the method index (ReadUIntBits(serverMethodsBitCount)).
  2. Looks up serverMethods[methodIndex].
  3. Checks rate limiting if rateLimitIndex >= 0.
  4. Creates a ServerInvocationContext from the reader and transport connection.
  5. Calls serverMethod.readMethod(context).

The ServerInvocationContext provides access to the ITransportConnection, the resolved SteamPlayer, and the reader. It enforces ONLY_FROM_OWNER validation.

Static vs Instance Methods

Static methods (ClientStaticMethod, ServerStaticMethod): Invoked with ReceiveDelegate or ReceiveDelegateWithContext. The receive method is directly called. No instance lookup needed.

Instance methods (ClientInstanceMethod, ServerInstanceMethod): Invoked on a specific component instance. The packet includes a NetId after the method index. The receive side looks up the component via NetIdRegistry.Get().

ClientStaticMethod API

ClientStaticMethod is the typed handle for server-to-client RPCs. It provides generic overloads for up to 12 type parameters:

csharp
public static ClientStaticMethod<T1, T2> Get(ReceiveDelegate action)
{
    return Get(action.Method.DeclaringType, action.Method.Name);
}

The Get() method looks up the ClientMethodInfo by declaring type and method name. The handle stores a WriteDelegate (generated serialize method) for packing arguments.

Invoke Variants

csharp
// Send to one client, no args
public void Invoke(ENetReliability reliability, ITransportConnection transportConnection)

// Send to one client with custom writer callback
public void Invoke(ENetReliability reliability, ITransportConnection transportConnection,
    Action<NetPakWriter> callback)

// Send with typed arguments
public void Invoke<T>(ENetReliability reliability, ITransportConnection transportConnection,
    Action<NetPakWriter, T> callback, T arg)

// Send to multiple clients
public void Invoke(ENetReliability reliability, List<ITransportConnection> transportConnections)

// Send to multiple clients, execute locally if any are local
public void InvokeAndLoopback(ENetReliability reliability, List<ITransportConnection> transportConnections)

Invoke Flow

csharp
public void Invoke<T>(ENetReliability reliability, ITransportConnection transportConnection,
    Action<NetPakWriter, T> callback, T arg)
{
    NetPakWriter writer = GetWriterWithStaticHeader();
    callback(writer, arg);  // Calls the generated write delegate
    SendAndLoopbackIfLocal(reliability, transportConnection, writer);
}
  1. GetWriterWithStaticHeader() obtains a NetPakWriter from the pool.
  2. Writes the client method index header.
  3. Calls the generated write delegate to serialize arguments.
  4. Calls transportConnection.Send() for remote connections.
  5. If the target is a loopback connection (singleplayer), directly calls readMethod() on the calling thread.

GetWriterWithStaticHeader

The static header writes:

  1. EClientMessage.InvokeMethod enum value.
  2. Method index (bit-packed using clientMethodsBitCount bits).

For instance methods, a NetId is also written after the method index:

[EClientMessage.InvokeMethod][methodIndex bits][NetId uint32][parameter bits...]

SendAndLoopback

csharp
protected void SendAndLoopbackIfLocal(ENetReliability reliability,
    ITransportConnection transportConnection, NetPakWriter writer)
{
    if (transportConnection is TransportConnection_Loopback)
    {
        reader.SetBufferSegment(writer.buffer, writer.writeByteIndex);
        reader.Reset();
        readMethod(new ClientInvocationContext(reader));
    }
    else
    {
        transportConnection.Send(writer.buffer, writer.writeByteIndex, reliability);
    }
}

For multi-client sends, SendAndLoopbackIfAnyAreLocal() checks each connection and invokes locally for any that are loopback.

ServerStaticMethod API

ServerStaticMethod is the typed handle for client-to-server RPCs. Its API mirrors ClientStaticMethod:

csharp
ServerStaticMethod<T>.Get(ReceiveDelegate action)

public void Invoke(ENetReliability reliability, Action<NetPakWriter, T> callback, T arg)
{
    NetPakWriter writer = GetWriterWithStaticHeader();
    generatedWrite(writer, arg);
    Provider.clientTransport.Send(writer.buffer, writer.writeByteIndex, reliability);
}

No loopback variant exists — client-to-server invocations from a local server host go through the net invokable system's direct call path.

Rate Limiting

When [SteamCall(ratelimitHz = N)] is specified, the server enforces that a single client cannot invoke the method more than N times per second:

csharp
if (customAttribute.ratelimitHz > 0)
{
    netMethod.rateLimitIndex = rateLimitedMethodsCount;
    customAttribute.rateLimitIndex = rateLimitedMethodsCount;
    customAttribute.ratelimitSeconds = 1.0f / customAttribute.ratelimitHz;
    ++rateLimitedMethodsCount;
}
else
{
    netMethod.rateLimitIndex = -1;
}

The server allocates a per-connection array of rateLimitedMethodsCount floats (timestamps). When a client invokes a rate-limited method, the server checks:

csharp
float currentTime = Time.realtimeSinceStartup;
if (currentTime - lastInvokeTimes[rateLimitIndex] < ratelimitSeconds)
{
    // Drop packet — client is exceeding the rate limit
    return;
}
lastInvokeTimes[rateLimitIndex] = currentTime;

This prevents clients from spamming methods like askSpawnVehicle or askDamagePlayer. The rate limit is per-connection, so one misbehaving client doesn't affect others.

NetId and Instance Routing

Instance methods use NetId — a 32-bit identifier that maps to a specific MonoBehaviour instance on both client and server.

NetIdRegistry

csharp
public static class NetIdRegistry
{
    public static T Get<T>(NetId id) where T : class;
    public static void Register(NetId id, object instance);
    public static void Release(NetId id);
    public static void Clear();
    public static NetId ClaimBlock(int count);
}

The NetId space is partitioned per-player on connect. ClaimNetIdBlockForNewPlayer() allocates 17 NetIds per player:

csharp
internal static NetId ClaimNetIdBlockForNewPlayer()
{
    return NetIdRegistry.ClaimBlock(17);
}

These 17 NetIds cover the Player component and its sub-components: inventory, life, skills, movement, look, clothing, equipment, crafting, quests, stance, voice, animator, interaction, input, and manager channels.

Instance Method Invocation

When an instance method is invoked:

  1. The sender writes the NetId after the method index.
  2. The receiver reads the NetId and looks up the component via NetIdRegistry.Get().
  3. The generated read method casts the component to the expected type and calls the method.

For example:

csharp
// Generated write for ServerInstanceMethod
public static void Write_PlayerLife_askDamage(NetPakWriter writer,
    byte damage, Vector3 force, EDeathCause cause, ELimb limb, CSteamID killer)
{
    writer.WriteUInt8(damage);
    writer.WriteClampedVector3(force);
    writer.WriteEnum(cause);
    writer.WriteEnum(limb);
    writer.WriteSteamID(killer);
}

// Generated read for ServerInstanceMethod
public static void Read_PlayerLife_askDamage(in ServerInvocationContext context)
{
    byte damage; Vector3 force; EDeathCause cause; ELimb limb; CSteamID killer;
    context.reader.ReadUInt8(out damage);
    context.reader.ReadClampedVector3(out force);
    context.reader.ReadEnum(out cause);
    context.reader.ReadEnum(out limb);
    context.reader.ReadSteamID(out killer);
    PlayerLife component = NetIdRegistry.Get<PlayerLife>(context.netId);
    component.askDamage(damage, force, cause, limb, killer);
}

NetEnum Code Generation

Enums used in networked methods must be marked with [NetEnum]. The code generator emits a NetEnum wrapper that handles bit-efficient serialization:

csharp
[NetEnum]
public enum EArenaMessage
{
    LOBBY, WARMUP, PLAY, DIED, ABANDONED, WIN, LOSE, INTERMISSION
}

Generates EArenaMessage_NetEnum.cs with read/write extension methods that use only 3 bits (for 8 values) instead of a full 32-bit int.

Deferred Invocations

NetInvocationDeferralRegistry supports deferred execution of RPC callbacks. When NetInvocationDeferMode is set on a method, the invocation is stored and replayed at a specific point in the frame:

csharp
public enum NetInvocationDeferMode
{
    None,
    AfterFixedUpdate,
    BeforeRender,
}

This is used for methods that must execute at a specific point to avoid race conditions with physics or animation systems.

ClientMethodHandle Base Class

ClientMethodHandle is the base class for ClientStaticMethod and ClientInstanceMethod. It provides:

csharp
public abstract class ClientMethodHandle
{
    protected ClientMethodInfo clientMethodInfo;
    protected static NetPakReader reader;

    protected NetPakWriter GetWriterWithStaticHeader()
    {
        NetPakWriter writer = NetMessages.GetInvokableWriter();
        writer.Reset();
        writer.WriteEnum(EClientMessage.InvokeMethod);
        writer.WriteUIntBits(clientMethodInfo.methodIndex, NetReflection.clientMethodsBitCount);
        return writer;
    }

    protected void SendAndLoopbackIfLocal(ENetReliability reliability,
        ITransportConnection transportConnection, NetPakWriter writer)
    {
        if (transportConnection is TransportConnection_Loopback)
        {
            reader.SetBufferSegment(writer.buffer, writer.writeByteIndex);
            reader.Reset();
            clientMethodInfo.readMethod(new ClientInvocationContext(reader));
        }
        else
        {
            transportConnection.Send(writer.buffer, writer.writeByteIndex, reliability);
        }
    }

    protected void SendAndLoopback(ENetReliability reliability,
        List<ITransportConnection> transportConnections, NetPakWriter writer)
    {
        bool hasLoopback = false;
        foreach (var conn in transportConnections)
        {
            if (conn is TransportConnection_Loopback)
            {
                hasLoopback = true;
            }
            else
            {
                conn.Send(writer.buffer, writer.writeByteIndex, reliability);
            }
        }
        if (hasLoopback)
        {
            reader.SetBufferSegment(writer.buffer, writer.writeByteIndex);
            reader.Reset();
            clientMethodInfo.readMethod(new ClientInvocationContext(reader));
        }
    }
}

The GetWriterWithStaticHeader() method writes:

  1. EClientMessage.InvokeMethod — the message type identifier (5 bits for the 19-value enum).
  2. methodIndex — bit-packed using clientMethodsBitCount bits.

For instance methods (handled by ClientInstanceMethod which extends this base), a NetId is also written after the method index, and the receive side looks up the component before calling the read method.

ServerMethodHandle Base Class

csharp
public abstract class ServerMethodHandle
{
    protected ServerMethodInfo serverMethodInfo;
    protected static NetPakReader reader;
    protected static NetPakWriter writer;

    protected NetPakWriter GetWriterWithStaticHeader()
    {
        writer.Reset();
        writer.WriteEnum(EServerMessage.InvokeMethod);
        writer.WriteUIntBits(serverMethodInfo.methodIndex, NetReflection.serverMethodsBitCount);
        return writer;
    }

    protected void Send(NetPakWriter writer, ENetReliability reliability)
    {
        Provider.clientTransport.Send(writer.buffer, writer.writeByteIndex, reliability);
    }
}

The client-side send path is simpler because there is no loopback — the send either goes to the server transport or, in the case of a listen server, the method is invoked directly on the server without going through the transport layer.

ClientInstanceMethod and ServerInstanceMethod

Instance methods add NetId routing:

csharp
public sealed class ClientInstanceMethod<T> : ClientMethodHandle
{
    public void Invoke(ENetReliability reliability, ITransportConnection transportConnection,
        NetId netId, T arg)
    {
        NetPakWriter writer = GetWriterWithStaticHeader();
        writer.WriteNetId(netId);
        generatedWrite(writer, arg);
        SendAndLoopbackIfLocal(reliability, transportConnection, writer);
    }
}

public sealed class ServerInstanceMethod<T> : ServerMethodHandle
{
    public void Invoke(ENetReliability reliability, NetId netId, T arg)
    {
        NetPakWriter writer = GetWriterWithStaticHeader();
        writer.WriteNetId(netId);
        generatedWrite(writer, arg);
        Send(writer, reliability);
    }
}

The generated read methods for instance methods include the NetId lookup:

csharp
// Generated server instance method read
public static void Read_PlayerLife_askDamage(in ServerInvocationContext context)
{
    NetId netId;
    context.reader.ReadNetId(out netId);
    byte damage;
    context.reader.ReadUInt8(out damage);
    // ... read other args
    PlayerLife component = NetIdRegistry.Get<PlayerLife>(netId);
    component.askDamage(damage, ...);
}

Performance Considerations

Delegate Creation Overhead

The generated write and read delegates are created once via Delegate.CreateDelegate() during NetReflection initialization. This means:

  • Zero per-call allocation for method dispatch.
  • No reflection overhead in the hot path — generated code is JIT-compiled to native.
  • Total registration takes ~50ms for the full Assembly-CSharp (measured by the Stopwatch in the static constructor).

Bit-Packed Method Index

Method indices are bit-packed to minimize wire size:

  • Client methods: clientMethodsBitCount bits (typically 7-9 bits for ~100-250 methods).
  • Server methods: serverMethodsBitCount bits.

This is significantly smaller than a full byte per method index. For a typical 200-client-method setup, the index takes 8 bits instead of 8 bits (no win here), but for smaller counts the saving is meaningful.

Loopback Optimization

For singleplayer, the loopback path avoids:

  1. Serialization to byte buffer (no generatedWrite call — just direct method call).
  2. Transport layer overhead (no Steam API call, no socket write).
  3. Deserialization from byte buffer (no generatedRead call — the original method is called directly).

This makes singleplayer networking effectively free.

Debug and Profiling Support

In development builds, ClientMethodInfo and ServerMethodInfo include profiler samplers:

csharp
#if UNITY_EDITOR || DEVELOPMENT_BUILD
internal int handleCount;
internal CustomSampler readSampler;
internal CustomSampler deferredReadSampler;
#endif

The Dump() method logs all registered methods with their indices:

csharp
public static void Dump()
{
    Log($"{clientMethods.Count} client methods ({clientMethodsBitCount} bits):");
    for (int index = 0; index < clientMethods.Count; ++index)
        Log($"{index} {clientMethods[index]}");
    Log($"{serverMethods.Count} server methods ({serverMethodsBitCount} bits):");
    for (int index = 0; index < serverMethods.Count; ++index)
        Log($"{index} {serverMethods[index]}");
}

LogHandleCount() checks that every built-in handle is claimed exactly once, reporting unclaimed and duplicate methods.

Assembly Registration

Mods can register additional RPC methods via:

csharp
public static void RegisterFromAssembly(Assembly assembly)

This allows mod assemblies to define their own [SteamCall] methods and generated netcode. The method scans the provided assembly for [NetInvokableGeneratedClass] types and processes them identically to the main assembly.