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
| Actor | On this page |
|---|---|
schema-author | declares RPCs, their modes and who may call them |
any actor | invokes an answering or a one-way call, where the declaration allows it |
group member | answers a fan-out call; one answer travels back per member |
At a glance
[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)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.
| Declares | What it is |
|---|---|
name | from the vocabulary of verbs |
input | the arguments the caller must choose |
output | exactly 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 mode | with 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 mode | immediate: 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 |
streaming | whether the input and the output arrive in parts and are handled as they arrive, rather than as a whole |
idempotency | a one-way RPC carries an idempotency key too: no reply does not mean no redelivery |
overridability | declared on the method itself. Without a declaration a method is not overridable |
context | where 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 |
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.
| Carries | What it is |
|---|---|
arguments | only what the caller must choose |
implicit context | the 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 |
references | an SDK object passed as an argument travels as a typed Ref: an identifier or a cursor, not a copy of its content |
outcome | a 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.
| Holds | What it is |
|---|---|
state | accepted → running → completed or failed, the last two terminal |
lifetime | declared; past it the outcome is unavailable and asking for it is a refusal, not an empty answer |
cancel | idempotent. It is a request: the terminal state you observe is whichever of completed or failed the work reached |
True of every RPC.
| Always | What it is |
|---|---|
one handler | exactly 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 machine | neither 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 stream | is 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 write | no 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 |
[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 ranexport 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 ranAttribute-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 ranErrors
- 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
forbiddenrefusal, 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.