Not translated yet — showing English.
Auth & Players
Sign-in is an overridable step, not a black box. Providers, sessions, identity linking, bans. Every point in the flow — before and after sign-in, before and after a link, before and after a merge, on a status change — is a declared extension point with a declared kind: a gate that can refuse the step, or an observer that cannot.
When to use it
- Players must sign in (device, email, Apple, Google, Steam or custom), with create-on-first-sign-in as a flag, not a second flow.
- A guest account must upgrade later:
Linkadds Steam with progress intact, and merges reconcile two accounts into one player. - Policy must run where it can't be skipped: a region gate before sign-in, a starter pack after the sign-in that created the player.
- Moderation must act at once: revoke sessions, suspend, device-ban, with a
bannedevent every live system hears. - Declared context (region, platform, build) must reach every later hook without each one re-reading the player to learn it.
- There is nothing lighter to skip to. Every other module names its caller through this one, and
authcannot be switched off while any of them needs a player actor: the module configurator refuses, and names the dependants.
Who does what
| Actor | On this page |
|---|---|
player | signs in, links or unlinks identities, refreshes, logs out |
moderator | revokes sessions; bans, suspends or restores players |
backend-service | gates sign-in by region; seeds a new player's first rows; reads and revokes sessions |
At a glance
SignIn call per provider, create-on-first-sign-in as a flag; Link adds Steam// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identities// client — one call per provider; create-on-first-sign-in is a flag
const session = await PlayServ.auth.signIn(Provider.Device, { create: true });
await PlayServ.auth.link(Provider.Steam); // one player, many identities# client — one call per provider; create-on-first-sign-in is a flag
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)
await playserv.auth.link(Provider.STEAM) # one player, many identitiesAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
// client — one call per provider; create-on-first-sign-in is a flag
Client->Auth->SignInWithProvider(FPSProviderId::Device, Credential,
TPSOnResult<FPSSession>::CreateWeakLambda(this, [this](const TPSResult<FPSSession>& Result)
{
if (!Result.HasValue()) { return; }
// one player, many identities — add Steam to the same account
Client->Auth->Providers->Link(FPSProviderId::Steam, SteamCredential);
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identitiesEach point is customised where it is declared; the shapes a handler can take are collected in Extensibility:
[Before(Auth.SignIn)] // a gate: it may refuse, and it is fail-closed
public static Verdict GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
[After(Auth.SignIn, created: true)] // an observer: it watches, it cannot refuse
public static async Task GrantStarterPack(Player player)
{
await player.Inventory.Grant("chest.gold", count: 1);
}// a gate: it may refuse, and it is fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// an observer: it watches, it cannot refuse
export const grantStarterPack = after(Auth.signIn, { created: true },
async (player: Player) => {
await player.inventory.grant('chest.gold', { count: 1 });
});@before(auth.sign_in) # a gate: it may refuse, and it is fail-closed
def gate_region(a: SignInAttempt) -> Verdict:
if a.region == "sanctioned":
return Hook.reject(Problem.FORBIDDEN, "region not served")
return Hook.continue_(a)
@after(auth.sign_in, created=True) # an observer: it watches, it cannot refuse
async def grant_starter_pack(player: Player):
await player.inventory.grant("chest.gold", count=1)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
An override and a hook are both cloud functions: they execute 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.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
The declared form is what the panel renders: every point shows its handlers, their kind and the resolved order. The kind decides what happens when a handler fails:
| Kind | When the handler itself fails | On refusal |
|---|---|---|
| a gate | the step is refused — an unreachable region check is not a passed region check | a code from the platform's catalogue plus a human reason. Callers branch on the code; the reason text is free to change and to be translated |
| an observer | the step stays done, so a starter pack that did not land costs a chest, not the sign-in | it cannot refuse |
A hook never decides who signed in
A gate answers yes or no about an identity the platform has already established. It doesn't name the player, hand out an identity, or stand in for the provider's confirmation.
The model
A player is a bearer of identity, not a row in your schema, and their identifier is stable and never reused — including on a merge: a merged player's id keeps resolving rather than becoming a dangling reference. A link is a triple: provider · external subject · player.
| Always | What it is |
|---|---|
provider + subject | is unique, and that uniqueness is a source of a conflict, not a prohibition: the answer to "this account is already taken" is to choose a merge |
at most one link per provider per player | a second account of the same provider is a conflict |
an external subject | is never a player's identifier: it belongs to the provider, and using it as ours would tie our ids to theirs |
identity kind and access status | are different axes: anonymous versus registered is one; active / suspended / banned is another, so "banned anonymous" and "suspended registered" are both expressible |
a session and a credential | are different things: a session is the record; a credential is what you present. Revoking a session invalidates all its credentials and closes its open subscriptions |
many simultaneous sessions | each revoked independently |
a credential's claims | are declared context — region, locale — and context only. A claim never carries authority |
a device fingerprint | is not an identity: it is never ground to admit, only ground to refuse, and it is stored and compared in an irreversible form |
The three machines.
| Of | States |
|---|---|
| the kind of identity | anonymous → registered, and the transition is one-way |
| the access status | active ⇄ suspended, and active → banned → active for an unban |
| the player | alive → merged, where merged is terminal: a merged player does not sign in again |
What the consumer declares.
| Declares | What it is |
|---|---|
sign-in policy | whether anonymous sign-in is allowed, and the rest of the rules around signing in. Declared as an attribute on the module's mount point — not a config file beside the code, and not constructed at runtime |
default role | the set a new player carries at first sign-in. There is no default for the default: declare nothing and new players arrive with no roles, which is a legitimate declaration rather than an omission |
session policy | what happens when the simultaneous-session ceiling is reached — evict the oldest with an event, or refuse the new one. No default |
deletion policy | how a player's deletion reaches the data that references them |
Where a provider is configured. In the operator plane, not in code — a store credential does not belong in a repository. What is declared reaches the admin console for reading.
What granting a role does. Roles are not only an operator's business: the surface carries grant and revoke for a player, so a game can promote a guild officer or hand a tournament host their powers from its own code.
| Always | What it is |
|---|---|
it is not self-promotion | granting requires the declared permission atom for it, and an actor without that atom gets a plain forbidden rather than a silent no-op |
granting is idempotent | granting a role the player already holds is a success, not a conflict: the state is the set of roles, not the history of calls, so unlike sign-in this operation needs no idempotency key |
a role change is not instant | it takes effect without reissuing the credential, and it is observable no later than the declared staleness bound on the rights cache — so code that grants a role and immediately checks it on a connected client has to design around that window |
What roles are made of and what they unlock is Access & Roles.
Errors
- No credential, or an expired one, answers not authenticated and a refresh fixes it. A revoked credential answers the same way but a refresh will not: only a new sign-in.
- A rotated credential presented again is a conflict — that is what makes rotation detectable rather than silently tolerated.
- A banned or suspended player, and a banned fingerprint, answer forbidden, and repeating is pointless.
- The pair provider+subject is taken is a conflict, repeatable after choosing a merge; a second account of the same provider is a conflict that a retry will not change.
- Unlinking the last sign-in method is a validation refusal: it would leave an account nobody can reach.
- Merging an already merged player is a conflict —
mergedis terminal. - The provider being unavailable answers unavailable and is worth retrying with backoff; the provider rejecting the credential answers not authenticated and is worth one retry, not a loop.
- The sign-in attempt rate exceeded answers in the rate-limit category with a deadline.
Limits
The numbers arrive with the platform limits chapter; what happens at each ceiling is fixed now.
- Simultaneous sessions per player: by the declared policy, eviction of the oldest with an event, or a refusal of the new one. There is no default.
- Sign-in attempts per period, and attempts to link a taken pair: a rate limit with a deadline, and the attempt counter stays in the history.
- Links per player: linking another provider is refused as a conflict.
- A credential's lifetime: not authenticated, repeatable by a refresh. A refresh credential's lifetime: only a new sign-in.
- Retention of an anonymous player with no sign-ins: deletion by the declared policy, with an event. The policy is declared explicitly; there is no default.
- Entries in the fingerprint ban list: an addition is refused, and old entries are never evicted silently.