Not translated yet — showing English.
Extensibility
Every platform scenario is a chain of registered functions: replace a link or wrap it. You change the platform's behaviour by replacing its steps, not by forking its source.
When to use it
- A platform step must run your logic: declare the replacement for a named link with
[Override(…)]. - You need checks or side effects around a step: ordered
Before/Aftermiddleware that can veto or notify. - Code must run on a schedule, an event or a webhook: triggers hand you a parsed, typed context.
- You must know what will actually run before deploy: dry-run a chain and read the resolved order.
- Skip it when the rule concerns one entity's writes. A Data hook is the lighter form.
Who does what
| Actor | On this page |
|---|---|
backend-service | overrides links, wraps steps with middleware, writes trigger handlers |
operator | inspects chains, sets order, reads secrets, dry-runs resolution |
At a glance
SignIn, wrap grant with middleware, run code on a cron// gate one named step of the auth scenario — a before hook may refuse, fail-closed
[Before(Auth.SignIn)]
public static Task<Verdict> GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
// wrap a step with ordered middleware
PlayServ.Extend.Scenario("commerce.purchase")
.Before("grant", LogPurchaseIntent)
.After("grant", NotifySquad, order: 10);
// customer code on a trigger
[OnSchedule("0 4 * * *")]
public static async Task NightlyCleanup() { ... }// gate one named step of the auth scenario — a before hook may refuse, fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// wrap a step with ordered middleware
playserv.extend.scenario('commerce.purchase')
.before('grant', logPurchaseIntent)
.after('grant', notifySquad, { order: 10 });
// customer code on a trigger
export const nightlyCleanup = onSchedule('0 4 * * *', async () => { /* ... */ });# gate one named step of the auth scenario — a before hook may refuse, fail-closed
@before(auth.sign_in)
async def gate_region(a):
if a.region == "sanctioned":
return hook.reject(problem.FORBIDDEN, "region not served")
return hook.cont(a)
# wrap a step with ordered middleware
playserv.extend.scenario("commerce.purchase") \
.before("grant", log_purchase_intent) \
.after("grant", notify_squad, order=10)
# customer code on a trigger
@on_schedule("0 4 * * *")
async def nightly_cleanup(): ...Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
The model
What the platform gives you to attach to.
| Term | What it is |
|---|---|
registered function | one overridable platform step — "create profile", "resolve price" |
scenario | the ordered chain a platform flow runs: auth, join, purchase |
overridability | whether a link may be replaced, wrapped only, or is fixed |
middleware | an ordered pre/post handler around a link |
trigger | what starts your code: an event, a schedule, a webhook |
secret | a value your handler may read |
invocation | one run, with its trace |
What a hook declares.
| Declares | What it is |
|---|---|
position | the named step it attaches to |
kind | gatekeeper — an admission check or a validation, and it fails closed, so the step does not run when the hook itself breaks; or observer — a log, a notification, a counter, and it fails open, the step runs, and the failure is still reported rather than swallowed. There is no default |
moment | before — ahead of validation, receiving the typed payload, and it may mutate or reject; or after — once the step committed, receiving request and result, side effects only, and it can never fail the operation or alter the response |
effect | what the hook does, not only where it sits. Ordering is then stated in terms of the work rather than numbers, so it survives someone else adding a hook beside yours |
version and condition | a handler that applies to one environment or audience is a declared version of that hook rather than a branch inside its body, and it is what the panel's toggle switches |
Three ways to attach code.
| Shape | Use it for |
|---|---|
[Before(Step)] / [After(Step)] attribute | one rule on one named step — most hooks |
[Override(Link)] attribute | replacing a link's implementation outright |
Extend.Scenario("…").Before("link", fn, order: n) | wrapping a link inside a chain, when order against other middleware matters |
| Always | What it is |
|---|---|
all three | deploy with playserv push |
the two attribute shapes | are what the panel renders, because the declaration carries the step or link name into the pushed model |
the middleware form | carries an order instead, which is what a chain needs |
assigning at startup | (Scenario.OnX = fn) stays available for a handler that does not need to appear in the admin tree |
replacing one link | leaves the links either side untouched, and neither of them knows which implementation answered — the platform's own step or yours |
Where a call goes, and what is true of every route.
| Always | What it is |
|---|---|
four directions | a cloud function · the consumer's external backend · the game server · another declared one |
the router | is message/signal-driven; request-response is one adapter onto it rather than its nature |
matching | is on the declared name of the operation or signal and on nothing else: not payload shape, not the caller, not load |
the direction | is not part of the operation's contract: moving a handler between directions is not a breaking change |
"the game server" | is defined by what it is, not by who hosts it — our fleet and a studio's own hosting are one direction, and the declaration carries no marker of who owns the infrastructure |
game-server RPCs | register in the same router: declaring one is registering it, and there is no second way |
ordering | runs middleware top to bottom, and where a step has more than one implementation the router picks left to right by condition, with the version marked default answering when nothing matched |
A chain with overrides and middleware can be resolved and read before anything runs. The resolved order is inspectable in the panel and from code.
What a constraint between hooks is, and when it is checked.
| What it is | |
|---|---|
a named constraint | an extension point may name the effects it constrains — an anti-cheat check must precede a placement, a receipt requires a charge at this point — and constrain nothing else |
an unnamed effect | is unconstrained, not refused |
a violation | is a defect of the configuration, and the refusal names both hooks and the constraint they broke — not a warning, and not a silent reordering |
when it is checked | at every act that can change what runs at a point: registering, deploying, changing the arrangement. So an arrangement that reaches execution has already been admitted |
never re-checked at run time | an admitted arrangement is not checked again when it executes |
| Always | What it is |
|---|---|
handlers | are typed in and out: no dynamic, no context bags. The platform handle is ambient, and the invocation context — caller, trigger, trace — arrives parsed |
what an engine build sees | the events the scenario emits afterwards, because an override or a middleware runs on the platform and an engine runtime is not a place to host one. That is what the engine tabs on this page's samples show |
[Rpc("resolve_price", Default = true)]
public static Price ResolvePrice(Sku sku) => Pricing.Base(sku);
[Rpc("resolve_price", When = "env == 'staging'")]
public static Price ResolvePriceStaging(Sku sku) => Pricing.WithDiscount(sku, 0.5f);
// a hook can carry a version too, gated by its own condition
[After("grant", When = "audience == 'beta'")]
public static void NotifySquadBeta(GrantResult r) => Messaging.PingBeta(r.Squad);export const resolvePrice = rpc('resolve_price', { default: true },
(sku: Sku) => Pricing.base(sku));
export const resolvePriceStaging = rpc('resolve_price', { when: "env == 'staging'" },
(sku: Sku) => Pricing.withDiscount(sku, 0.5));
// a hook can carry a version too, gated by its own condition
export const notifySquadBeta = after('grant', { when: "audience == 'beta'" },
(r: GrantResult) => Messaging.pingBeta(r.squad));@rpc("resolve_price", default=True)
def resolve_price(sku: Sku) -> Price:
return pricing.base(sku)
@rpc("resolve_price", when="env == 'staging'")
def resolve_price_staging(sku: Sku) -> Price:
return pricing.with_discount(sku, 0.5)
# a hook can carry a version too, gated by its own condition
@after("grant", when="audience == 'beta'")
def notify_squad_beta(r: GrantResult):
messaging.ping_beta(r.squad)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
The platform's own logic can be pulled, edited and redeployed. It is functions, like yours:
playserv functions pull matchmaking.match # the platform's implementation, as source
# edit: widen the skill window for the weekend event
playserv push # your version registers; the default stays as fallback
What you customise here is logic: the overrides, middleware and versions this page is about.
You can't add fields to platform entities
A player, a room, a leaderboard record and an order are system state with their own machines, not the start of your data model. Your data is your own entity, declared in Schema as Code and tied to the platform's state by a predicate: owner is this player, scope is this room. That also keeps it yours when the platform's own model changes.
Errors
- A name that matches no registered handler answers
not found— a call is never quietly dropped because nobody was listening. - A name registered twice, and a set with two implementations claiming the same condition, are refused when the set is declared : at deploy, not at call time.
- A hook's refusal carries the hook's own code and reason, so "a rule of the game said no" never arrives looking like a transport failure.
- A hook that fails behaves by its declared kind —
fail-openorfail-closed— and which one it is was declared rather than inferred from what happened. - A hook may not change what is already claimed: not the owner, not the target, not the board or conversation a call was addressed to. It corrects inputs and returns a verdict.
Limits
The numbers arrive with the platform limits chapter; what happens at each ceiling is fixed now.
- A hook's execution deadline: past it, the failure behaviour its kind declares.
- Hooks at one position, and implementations of one method: registering another is refused.
- The nesting depth of "a hook invokes an operation that has hooks": a declared refusal, never resource exhaustion.
- The size of the context passed to a hook: truncation is forbidden, so registration is refused instead of a handler receiving half a context.
Lessons & recipes: a leaderboard in Tanks uses this module's hooks; a daily tournament runs through this module.