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:
- Enumerate all types in the assembly.
- For each type with
[NetInvokableGeneratedClass], collect its methods with[NetInvokableGeneratedMethod]into read and write lists. - Reflect the target type's methods for
[SteamCall]attributes. - For each
[SteamCall]method:- If
ONLY_FROM_SERVER: create aClientMethodInfo. - If
SERVERSIDEorONLY_FROM_OWNER: create aServerMethodInfo.
- If
- Match each source method to its generated read/write by method name.
- Create
ClientMethodReceive/ServerMethodReceivedelegates from the generated read methods. - Store write
MethodInfofor later delegate creation (lazily viaCreateClientWriteDelegate()/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'sITransportConnectionmatches 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():
- Reads the method index (
ReadUIntBits(clientMethodsBitCount)). - Looks up
clientMethods[methodIndex]. - Creates a
ClientInvocationContextfrom the reader. - 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():
- Reads the method index (
ReadUIntBits(serverMethodsBitCount)). - Looks up
serverMethods[methodIndex]. - Checks rate limiting if
rateLimitIndex >= 0. - Creates a
ServerInvocationContextfrom the reader and transport connection. - 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);
}GetWriterWithStaticHeader()obtains aNetPakWriterfrom the pool.- Writes the client method index header.
- Calls the generated write delegate to serialize arguments.
- Calls
transportConnection.Send()for remote connections. - If the target is a loopback connection (singleplayer), directly calls
readMethod()on the calling thread.
GetWriterWithStaticHeader
The static header writes:
EClientMessage.InvokeMethodenum value.- Method index (bit-packed using
clientMethodsBitCountbits).
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:
- The sender writes the NetId after the method index.
- The receiver reads the NetId and looks up the component via
NetIdRegistry.Get(). - 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:
EClientMessage.InvokeMethod— the message type identifier (5 bits for the 19-value enum).methodIndex— bit-packed usingclientMethodsBitCountbits.
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:
clientMethodsBitCountbits (typically 7-9 bits for ~100-250 methods). - Server methods:
serverMethodsBitCountbits.
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:
- Serialization to byte buffer (no
generatedWritecall — just direct method call). - Transport layer overhead (no Steam API call, no socket write).
- Deserialization from byte buffer (no
generatedReadcall — 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;
#endifThe 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.
