Skip to main content
Building blocks

Not translated yet — showing English.

RPC

A typed call whose body lives somewhere else. Declare the procedure where it belongs, on a module or inside an entity, and every binding gets a generated, awaitable method. The verb is invoke; one-way is a mode of the declaration, not a separate verb.

When to use it

  • The caller needs an answer: request/response with a typed return.
  • The caller reports and moves on: a declared one-way RPC, nothing travels back.
  • The work outlasts the call: a declared deferred RPC hands back a work descriptor instead of timing out.
  • One question, many answerers: a group call is N calls, and each answer arrives bound to the member who sent it.
  • The verb belongs to a thing: declare it inside the entity, where it lives and nowhere else (Entity shows the declaration).
  • Skip it when nobody is asked to act. A fact others merely react to is an event.

Who does what

ActorOn this page
schema-authordeclares RPCs, their modes and who may call them
any actorinvokes an answering or a one-way call, where the declaration allows it
group memberanswers a fan-out call; one answer travels back per member

At a glance

Declare an answering and a one-way RPC; invoke both, then fan out to a group
[Rpc]                                        // answering, immediate, not overridable — the bare defaults
public static ScoreVerdict SubmitScore(ScoreReport report) => Scores.Judge(report);

[Rpc(OneWay = true)]                         // declared one-way: nothing travels back
public static void ReportPing(PingSample sample) => Metrics.Add(sample);

// invoking — generated, typed, awaitable
var verdict = await playserv.Rpc.Invoke.SubmitScore(report);
playserv.Rpc.Invoke.ReportPing(sample);      // one-way by declaration, not by call site

// group fan-out: N calls, one answer bound to each member
await foreach (var answer in squad.Invoke.ReadyCheck())
    Hud.Mark(answer.Member, answer.Ready);
export class MatchRpcs {
  @Rpc()                                     // answering, immediate, not overridable — the bare defaults
  static submitScore(report: ScoreReport): ScoreVerdict { return Scores.judge(report); }

  @Rpc({ oneWay: true })                     // declared one-way: nothing travels back
  static reportPing(sample: PingSample): void { Metrics.add(sample); }
}

// invoking — generated, typed, awaitable
const verdict = await playserv.rpc.invoke.submitScore(report);
playserv.rpc.invoke.reportPing(sample);      // one-way by declaration, not by call site

// group fan-out: N calls, one answer bound to each member
for await (const answer of squad.invoke.readyCheck())
  hud.mark(answer.member, answer.ready);
@rpc()                                       # answering, immediate, not overridable — the bare defaults
def submit_score(report: ScoreReport) -> ScoreVerdict:
    return scores.judge(report)

@rpc(one_way=True)                           # declared one-way: nothing travels back
def report_ping(sample: PingSample):
    metrics.add(sample)

# invoking — generated, typed, awaitable
verdict = await playserv.rpc.invoke.submit_score(report)
playserv.rpc.invoke.report_ping(sample)      # one-way by declaration, not by call site

# group fan-out: N calls, one answer bound to each member
async for answer in squad.invoke.ready_check():
    hud.mark(answer.member, answer.ready)
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

// invoking — generated, typed (the client surface; bodies live where routing sends them)
Client->Rpc->Call->SubmitScore(Report,
    TPSOnResult<FScoreVerdict>::CreateWeakLambda(this, [this](const TPSResult<FScoreVerdict>& Result)
    {
        if (!Result.HasValue()) { return; }
        Hud->ShowVerdict(Result.Value());
    }));
Client->Rpc->CallOneWay->ReportPing(Sample);   // one-way by declaration, not by call site

// group fan-out: one call, one answer bound to each member
Squad->Call->ReadyCheck(TPSOnResult<FReadyAnswer>::CreateWeakLambda(this,
    [this](const TPSResult<FReadyAnswer>& Answer)
    {
        if (!Answer.HasValue()) { return; }
        Hud->Mark(Answer.Value().Member, Answer.Value().Ready);   // the delegate fires once per member
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// Unity invokes; RPC bodies execute on the platform or a host — engines are not a handler runtime
var verdict = await playserv.Rpc.Invoke.SubmitScore(report);
playserv.Rpc.Invoke.ReportPing(sample);      // one-way by declaration, not by call site

await foreach (var answer in squad.Invoke.ReadyCheck())
    Hud.Mark(answer.Member, answer.Ready);

Where the body executes — cloud function, client, master-client or game server — is routing, declared per method; Extensibility covers overrides and middleware. A failed call throws a typed Problem (Core).

The model

What an RPC declares.

DeclaresWhat it is
namefrom the vocabulary of verbs
inputthe arguments the caller must choose
outputexactly one declared type. A shorter reply is its own declared type, not the same type with fields left out; otherwise "not asked for", "absent" and "hidden by the access mask" would look the same
reply modewith a reply: a value of the declared output type or a typed refusal. one-way: no reply; the caller learns only of a local send failure
execution modeimmediate: the outcome returns within the call. deferred: the call returns a work descriptor, and the outcome is read or arrives by subscription. Declared, not chosen at run time by load, because callers depend on the shape of the reply
streamingwhether the input and the output arrive in parts and are handled as they arrive, rather than as a whole
idempotencya one-way RPC carries an idempotency key too: no reply does not mean no redelivery
overridabilitydeclared on the method itself. Without a declaration a method is not overridable
contextwhere it is declared. An RPC declared inside an entity is part of that entity and does not exist outside it. Declaring one in the game server is registering it in the router — there is no second way to add one
with a reply · immediatewith a reply · deferredone-wayinvokethe outcome, or a typed refusalinvokea work descriptor, at oncethe outcome, laterinvokeno reply within the callread it, or subscribeonly a local send failure
What the caller gets back: the outcome within the call, a work descriptor now and the outcome later, or no reply

Don't use one-way when you need the outcome

A one-way call tells the caller only about a local send failure. If you need to know what happened, declare a reply: an unknown outcome costs more than a known refusal.

What an invocation carries.

CarriesWhat it is
argumentsonly what the caller must choose
implicit contextthe receiver, the caller and the ambient context, bound before your first written parameter — an entity's method is never asked for that entity's identifier
referencesan SDK object passed as an argument travels as a typed Ref: an identifier or a cursor, not a copy of its content
outcomea value of the declared output type, or a typed Problem

A reference is an address, not a permission

The recipient resolves a Ref on its own behalf, under its own permissions and predicates. Passing a reference doesn't grant access to what it points at.

What a deferred call's descriptor holds.

HoldsWhat it is
stateaccepted → running → completed or failed, the last two terminal
lifetimedeclared; past it the outcome is unavailable and asking for it is a refusal, not an empty answer
cancelidempotent. It is a request: the terminal state you observe is whichever of completed or failed the work reached

True of every RPC.

AlwaysWhat it is
one handlerexactly one logical handler. An RPC asks for an action and its handler must exist; an event states a fact and may have no recipients at all. So a one-way RPC is not an event, and addressing a group is N calls, not one: Groups supplies the addresses, and the answers come back as a stream, each bound to the member who sent it
no state machineneither a declaration nor an immediate call has one: the call returned an outcome or it did not, and then the timeout rules apply. Only a deferred call has observable states
a streamis not atomic. A streaming output promises nothing about the whole, so a receiver must handle an interruption and tell "completed" from "interrupted"
no predicate on a writeno write accepts a predicate as input: "do this for everyone who matches this condition" is not an operation. A bulk action is expressed by enumeration: read the set, then hand the list to a batch operation with declared partial-failure semantics
A deferred RPC: the call returns a work descriptor, the outcome arrives against it
[Rpc(Execution = Execution.Deferred)]        // minutes of work — an answer inside the call would be a timeout
public static MatchReport BuildMatchReport(MatchId match) => Reports.Build(match);

var work = await playserv.Rpc.Invoke.BuildMatchReport(matchId);   // the descriptor, not the report
work.OnOutcome(report => Hud.ShowReport(report));                 // or read it later, by descriptor
await work.Cancel();                                              // a request, not a promise nothing ran
export class ReportRpcs {
  @Rpc({ execution: Execution.Deferred })    // minutes of work — an answer inside the call would be a timeout
  static buildMatchReport(match: MatchId): MatchReport { return Reports.build(match); }
}

const work = await playserv.rpc.invoke.buildMatchReport(matchId);  // the descriptor, not the report
work.onOutcome((report) => hud.showReport(report));                // or read it later, by descriptor
await work.cancel();                                               // a request, not a promise nothing ran
@rpc(execution=Execution.DEFERRED)           # minutes of work — an answer inside the call would be a timeout
def build_match_report(match: MatchId) -> MatchReport:
    return reports.build(match)

work = await playserv.rpc.invoke.build_match_report(match_id)   # the descriptor, not the report
work.on_outcome(lambda report: hud.show_report(report))         # or read it later, by descriptor
await work.cancel()                                             # a request, not a promise nothing ran
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

// the client side of a deferred call: a descriptor now, the outcome against it later
Client->Rpc->Call->BuildMatchReport(MatchId,
    TPSOnResult<FPSDeferredHandle>::CreateWeakLambda(this, [this](const TPSResult<FPSDeferredHandle>& Result)
    {
        if (!Result.HasValue()) { return; }
        const FPSDeferredHandle Work = Result.Value();
        TPSSubscription ReportWatch = Client->Rpc->Deferred->Subscribe(Work,
            [this](const FMatchReport& Report) { Hud->ShowReport(Report); });
        Client->Rpc->Deferred->Cancel(Work);   // a request, not a promise nothing ran
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the client side of a deferred call: a descriptor now, the outcome against it later
var work = await playserv.Rpc.Invoke.BuildMatchReport(matchId);
work.OnOutcome(report => Hud.ShowReport(report));
await work.Cancel();                         // a request, not a promise nothing ran

Errors

  • The right is on the RPC, never on the primitive. There is no blanket "may invoke": each declaration names the atom its caller must hold. A caller without it gets a typed forbidden refusal, not a silent drop.
  • A hidden instance reads as "not found". An entity RPC invoked on an instance the caller's row predicate hides answers exactly as reading that instance would, so the refusal tells the caller nothing about what exists.
  • No handler is "unavailable", not "not found". The RPC is declared, so it exists; what is missing is a route. It is worth retrying, since a game server can come back; "not found" would tell the caller to stop.
  • A hook refusal carries the hook's own code and reason, so "rejected by a rule of the game" never arrives looking like "the transport broke".
  • A timeout is not an outcome. For a deferred call you read the descriptor; for an immediate one, the declared idempotency key makes the retry safe.

Limits

The numbers arrive with the platform limits chapter; what happens at each ceiling is fixed now.

  • Input size: the call is refused before execution.
  • Output size: refused rather than truncated, because a trimmed answer can't be told from a full one.
  • Call rate per actor: a rate-limit refusal naming when to retry.
  • Concurrent deferred calls per actor: the new one is refused, the ones in flight finish.
  • Descriptor lifetime: past it the outcome is unavailable, and asking is a refusal.
  • Call-chain depth: a declared refusal when exceeded, not exhausted resources or a silent break.