Skip to main content
Version: 0.3.1

Custom RPC

Server RPC lets a client send a request to the server and, optionally, receive a response. Unlike client-relayed RPC, the server always runs your code first: it decides whether and how to reply.

A server RPC is defined once, as a contract shared between the server mod and the client mod, in a common project both reference. The contract declares the shape of each direction of the message. The server mod implements the handler, and the client mod implements the response handler described in Handling server RPC on the client.

Declaring a contract

A contract is a partial static class decorated with [ServerRpcContracts], containing one partial method per RPC. Each method must be marked with [ClientToServer], [ServerToClient], or both, declaring which direction(s) it carries:

A contract with a one-way push and a request/response pair
using ReadyM.Api.Multiplayer;

[ServerRpcContracts]
public static partial class RpcContracts
{
// one-way push: only the server can send this
[ServerToClient] public static partial void BeguilingChant(byte state);

// request/response: the two directions can have different payloads, since they
// are declared as separate overloads
[ClientToServer] public static partial void ScaleBossHp(int scalingPercent);
[ServerToClient] public static partial void BossHpScaleConfirm(int scalingPercent, int players);
}
  • A method with only [ClientToServer] is a one-way command: the client sends it, the server handles it, and there is no reply.
  • A method with only [ServerToClient] is a one-way push: only the server can send it.
  • Two overloads of the same name, one of each attribute, form a request/response pair. Their payloads do not need to match.
  • A single method carrying both attributes is a symmetric two-way message, sharing one payload shape.

Parameters follow the same rules as client-relayed RPC: primitive types or [DeriveINetSerializable] structs.

important

The contract project is referenced by both the server mod and the client mod, and both sides read the wire codes from it. Recompile and ship them together, or the two sides will disagree about which code means what.

Declaring server-side handlers

Implement the server side of a contract in a partial class extending ServerRpcHandlersBase, annotated with [ServerRpcFor] naming the contract class it implements. For every [ClientToServer] leg of that contract, define a matching On... partial method. The SDK generates the corresponding Send... method for any [ServerToClient] leg.

Server-side RPC handler
[ServerRpcFor(typeof(RpcContracts))]
public partial class RpcHandlers(ScaleHpSystem hpScaling, EcsApi ecs) : ServerRpcHandlersBase
{
partial void OnScaleBossHp(RpcContext context, int scalingPercent)
{
hpScaling.ScalingPercent = scalingPercent;

var players = 0;
ecs.Query<MainCharacterComponent, int>(ref players, static (ref _, ref players) => { players++; });

// confirm to everyone, since the setting affects the whole server
ecs.Query<MainCharacterComponent>((ref player) =>
{
// generated by the SDK
SendBossHpScaleConfirm(player.PlayerId, scalingPercent, players);
});
}
}

The RpcContext parameter is always injected first and exposes Sender, the PlayerId of the client that sent the request. Generated Send... methods take a PlayerId recipient as their first parameter, followed by the response payload. There is no broadcast overload: to reach every player, query the main characters and send to each, as above.

Binding a class to its contract

[ServerRpcFor] is required on every server handler and client RPC class, and takes the [ServerRpcContracts] class it implements. Your mod may reference several contract sets, directly or through a chain of shared projects, and this is what tells the generator which one to emit against.

Only the legs declared by the named contract class are generated, so a class implements exactly one contract set. If you have several contract classes, give each its own handler class:

One handler class per contract set
[ServerRpcFor(typeof(BossContracts))]
public partial class BossRpc(EcsApi ecs) : ServerRpcHandlersBase { /* boss legs only */ }

[ServerRpcFor(typeof(ArenaContracts))]
public partial class ArenaRpc(EcsApi ecs) : ServerRpcHandlersBase { /* arena legs only */ }

Omitting the attribute is a compile error (SRPC004), as is naming a class that is not annotated with [ServerRpcContracts] (SRPC005).

important

For any of the RPC handlers to be registered, the class must be added to the dependency injection container in your mod's Init method.

protected override void Init()
{
Services.RegisterSingleton<RpcHandlers>();
}

Replying to just the sender

When the response is only meaningful to the client that asked, match the sender against the entity you are about to read. Pairing your component with MainCharacterComponent gives you the PlayerId to compare against:

Answering one player
partial void OnGetBounty(RpcContext context)
{
ecs.Query<MainCharacterComponent, BountyComponent>((ref main, ref bounty) =>
{
if (main.PlayerId == context.Sender)
{
SendGetBounty(context.Sender, bounty.Kills);
}
});
}

A client can only ask the server to act on its own behalf, but nothing stops a malformed or malicious request from naming someone else's entity or passing an out-of-range value. Always check context.Sender against the entity you are about to modify, and validate payloads before applying them.

Handlers run on the network thread

On... methods are invoked on the network thread as the packet is parsed, not on the server tick. Reading and writing components through EcsApi from a handler is supported, but long-running work is not: it blocks packet processing for every client. If the response depends on state that only a system can produce, store the request and let the system act on it, as shown in Gameplay systems.

Client side

See Handling server RPC on the client for how to send requests and handle responses from your client-side mod.