Skip to main content
Building blocks

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/After middleware 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

ActorOn this page
backend-serviceoverrides links, wraps steps with middleware, writes trigger handlers
operatorinspects chains, sets order, reads secrets, dry-runs resolution

At a glance

Three extension shapes: gate 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(): ...
Coming soon — Go

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

Runs off the engine

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.

Runs off the engine

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.

TermWhat it is
registered functionone overridable platform step — "create profile", "resolve price"
scenariothe ordered chain a platform flow runs: auth, join, purchase
overridabilitywhether a link may be replaced, wrapped only, or is fixed
middlewarean ordered pre/post handler around a link
triggerwhat starts your code: an event, a schedule, a webhook
secreta value your handler may read
invocationone run, with its trace
a platform scenario — a purchasevalidateresolve price — your [Override], chosen by condition; ours is the defaultyour Before hook — a gatekeeper: may reject; if it breaks, grant does not rungrantyour After hook — an observer: side effects only; if it breaks, grant still standsnotify
Your code inside a platform scenario: one link replaced, one wrapped; the links either side don't know which implementation answered

What a hook declares.

DeclaresWhat it is
positionthe named step it attaches to
kindgatekeeper — 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
momentbefore — 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
effectwhat 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 conditiona 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.

ShapeUse it for
[Before(Step)] / [After(Step)] attributeone rule on one named step — most hooks
[Override(Link)] attributereplacing a link's implementation outright
Extend.Scenario("…").Before("link", fn, order: n)wrapping a link inside a chain, when order against other middleware matters
AlwaysWhat it is
all threedeploy with playserv push
the two attribute shapesare what the panel renders, because the declaration carries the step or link name into the pushed model
the middleware formcarries 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 linkleaves 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.

AlwaysWhat it is
four directionsa cloud function · the consumer's external backend · the game server · another declared one
the routeris message/signal-driven; request-response is one adapter onto it rather than its nature
matchingis on the declared name of the operation or signal and on nothing else: not payload shape, not the caller, not load
the directionis 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 RPCsregister in the same router: declaring one is registering it, and there is no second way
orderingruns 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 constraintan 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 effectis unconstrained, not refused
a violationis 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 checkedat 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 timean admitted arrangement is not checked again when it executes
AlwaysWhat it is
handlersare 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 seesthe 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
Two implementations of one function, chosen by condition with a default; a hook version gated the same way
[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)
Coming soon — Go

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

Runs off the engine

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.

Runs off the engine

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-open or fail-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.