Skip to main content
Services around the game

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: Link adds 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 banned event 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 auth cannot be switched off while any of them needs a player actor: the module configurator refuses, and names the dependants.

Who does what

ActorOn this page
playersigns in, links or unlinks identities, refreshes, logs out
moderatorrevokes sessions; bans, suspends or restores players
backend-servicegates sign-in by region; seeds a new player's first rows; reads and revokes sessions

At a glance

One 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 identities
Coming soon — Go

Attribute-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.
Unity C# is the same C# API here — runs as-is in Unity against the generated types
// 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

Each point is customised where it is declared; the shapes a handler can take are collected in Extensibility:

A gate before sign-in refuses a region; an observer after the sign-in that created the player grants a starter pack
[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)
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

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.

Runs off the engine

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:

KindWhen the handler itself failsOn refusal
a gatethe step is refused — an unreachable region check is not a passed region checka 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 observerthe step stays done, so a starter pack that did not land costs a chest, not the sign-init 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

device · subject (the guest)Steam · subject (via Link)another player, same persona player — one stable id, never reused linklink: anonymous → registered, one waymerge: terminal; its id keeps resolving
Links are provider · subject · player: a link upgrades a guest one way, and a merge keeps the merged id resolving

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.

AlwaysWhat it is
provider + subjectis 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 playera second account of the same provider is a conflict
an external subjectis 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 statusare 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 credentialare 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 sessionseach revoked independently
a credential's claimsare declared context — region, locale — and context only. A claim never carries authority
a device fingerprintis 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.

OfStates
the kind of identityanonymous → registered, and the transition is one-way
the access statusactive ⇄ suspended, and active → banned → active for an unban
the playeralive → merged, where merged is terminal: a merged player does not sign in again

What the consumer declares.

DeclaresWhat it is
sign-in policywhether 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 rolethe 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 policywhat happens when the simultaneous-session ceiling is reached — evict the oldest with an event, or refuse the new one. No default
deletion policyhow 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.

AlwaysWhat it is
it is not self-promotiongranting 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 idempotentgranting 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 instantit 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 — merged is 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.